-
-
-
-
+ return
+
+
{t("providerConnections.title")}
{t("providerConnections.description")}
+
+
+ {adding &&
+
+
+
+
+
setToken(event.target.value)} />
+ {create.isError &&
}
+
+
}
+
+ {accounts.isLoading
+ ?
+ : accounts.isError
+ ?
void accounts.refetch()} />
+ : !accounts.data?.items.length
+ ?
+ : {accounts.data.items.map((account) =>
)}
}
+
+
+
+}
+
+function ProviderConnectionRow({ account }: { account: ProviderConnection }) {
+ const { formatError, t } = useI18n()
+ const queryClient = useQueryClient()
+ const [replacing, setReplacing] = useState(false)
+ const [token, setToken] = useState("")
+ const invalidate = () => void queryClient.invalidateQueries({ queryKey: ["provider-connections"] })
+ const remove = useMutation({ mutationFn: () => api(`/me/provider-connections/${account.id}`, { method: "DELETE" }), onSuccess: invalidate })
+ const test = useMutation({ mutationFn: () => api(`/me/provider-connections/${account.id}/test`, { method: "POST", body: "{}" }), onSuccess: invalidate })
+ const setDefault = useMutation({ mutationFn: () => api(`/me/provider-connections/${account.id}/default`, { method: "POST", body: "{}" }), onSuccess: invalidate })
+ const replace = useMutation({
+ mutationFn: () => api(`/me/provider-connections/${account.id}`, { method: "PATCH", body: JSON.stringify({ token }) }),
+ onSuccess: () => { setReplacing(false); setToken(""); invalidate() },
+ })
+ const title = account.displayName || account.login || account.providerType
+ const error = replace.error ?? remove.error ?? test.error ?? setDefault.error
+
+ return
+
+
+
+
{title}
{account.isDefault &&
{t("providerConnections.default")}}
+
{account.providerType} · {account.baseUrl}
+
{t("providerConnections.privateHint")}
- {create.isError &&
}
-
- }
-
+
+
+
+
+ {!account.isDefault && }
+
+
+
+ {test.isSuccess &&
{t("providerConnections.testSucceeded")}
}
+ {replacing &&
+ setToken(event.target.value)} />
+
+
}
+ {error &&
}
}
-function RepositoryList({ items, loading = false, compact = false, workspaceID }: { items: Repository[]; loading?: boolean; compact?: boolean; workspaceID?: string }) {
+function Repositories({ workspace, selectedRepositoryID, navigate }: { workspace: Workspace; selectedRepositoryID: string; navigate: (page: PageID, repositoryID?: string) => void }) {
const { t } = useI18n()
- const queryClient = useQueryClient(); const sync = useMutation({ mutationFn: ({ id, ref }: { id: string; ref: string }) => api(`/workspaces/${workspaceID}/repositories/${id}/sync`, { method: "POST", body: JSON.stringify({ ref }) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) })
- const cancel = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspaceID}/repositories/${id}/cancel-sync`, { method: "POST", body: "{}" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) })
- const remove = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspaceID}/repositories/${id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] }) })
- if (loading) return
- if (!items.length) return
- return
{items.map((item) =>
{item.cloneUrl}
{item.ref || t("repositories.remoteDefault")} · {item.currentCommitSha?.slice(0, 12) || t("repositories.notSynced")}
{item.lastErrorMessage &&
{item.lastErrorMessage}
}
{!compact && <>{item.state === "syncing" ?
:
}
>}
)}
-}
+ const [adding, setAdding] = useState(false)
+ const [search, setSearch] = useState("")
+ const repositories = usePagedQuery
(["repositories", workspace.id], `/workspaces/${workspace.id}/repositories`, { refetchInterval: 4_000 })
+ const items = repositories.data?.items ?? []
+ const filteredItems = items.filter((repository) => `${repository.name} ${repository.remoteUrl} ${repository.ref}`.toLowerCase().includes(search.trim().toLowerCase()))
+ const selected = items.find((repository) => repository.id === selectedRepositoryID) ?? items[0]
-type ConnectionForm = { name: string; type: string; baseUrl: string; authType: string; username: string; token: string }
+ return
+
setAdding(true)}>{t("repositories.add")}}
+ />
+ {repositories.isLoading
+ ?
+ : repositories.isError
+ ? void repositories.refetch()} />
+ : !items.length
+ ? setAdding(true)}>{t("repositories.add")}} />
+ :
+
+ {selected &&
}
+
}
+ setAdding(false)}> setAdding(false)} />
+
+}
-function Connections({ workspace }: { workspace: Workspace }) {
+function RepositoryCreate({ workspace, onDone }: { workspace: Workspace; onDone: () => void }) {
const { formatError, t } = useI18n()
const queryClient = useQueryClient()
- const [adding, setAdding] = useState(false)
- const { register, handleSubmit, reset, setValue } = useForm({
- defaultValues: { name: "", type: "github", baseUrl: "https://github.com", authType: "token", username: "", token: "" },
- })
- const connections = useQuery({ queryKey: ["connections", workspace.id], queryFn: () => api<{ items: Connection[] }>(`/workspaces/${workspace.id}/scm-connections`) })
+ const [remoteUrl, setRemoteUrl] = useState("")
+ const [repositoryRef, setRepositoryRef] = useState("")
+ const [name, setName] = useState("")
+ const [providerConnectionId, setProviderConnectionId] = useState("")
+ const connections = usePagedQuery(["provider-connections"], "/me/provider-connections")
+ const activeConnections = (connections.data?.items ?? []).filter((item) => item.status === "active")
const create = useMutation({
- mutationFn: (form: ConnectionForm) => api(`/workspaces/${workspace.id}/scm-connections`, { method: "POST", body: JSON.stringify({ name: form.name, type: form.type, baseUrl: form.baseUrl, authType: form.authType, secrets: form.token ? { username: form.username, token: form.token } : {} }) }),
+ mutationFn: () => api(`/workspaces/${workspace.id}/repositories`, {
+ method: "POST",
+ body: JSON.stringify({
+ name: name.trim(),
+ remoteUrl: remoteUrl.trim(),
+ ref: repositoryRef.trim(),
+ ...(providerConnectionId ? { providerConnectionId } : {}),
+ }),
+ }),
onSuccess: () => {
- setAdding(false)
- reset()
- void queryClient.invalidateQueries({ queryKey: ["connections", workspace.id] })
+ void queryClient.invalidateQueries({ queryKey: ["repositories", workspace.id] })
+ onDone()
},
})
- return setAdding((value) => !value)}>{t("connections.new")}} />
- {adding && }
- {connections.isLoading ?
: !connections.data?.items.length ?
:
{connections.data.items.map((item) => )}
}
+
+ return
+
+ setRemoteUrl(event.target.value)} placeholder="https://github.com/owner/repository.git" />
+
+
setRepositoryRef(event.target.value)} placeholder={t("repositories.targetExample")} />
+
setName(event.target.value)} placeholder={t("repositories.namePlaceholder")} />
+
+
+ {create.isError &&
}
+
}
-function ConnectionRow({ item, workspaceID }: { item: Connection; workspaceID: string }) {
- const { formatError, t } = useI18n()
- const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [name, setName] = useState(item.name); const [token, setToken] = useState(""); const [repositoryUrl, setRepositoryUrl] = useState("")
- const update = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/scm-connections/${item.id}`, { method: "PATCH", body: JSON.stringify({ name, ...(token ? { secrets: { token } } : {}) }) }), onSuccess: () => { setToken(""); void queryClient.invalidateQueries({ queryKey: ["connections", workspaceID] }) } })
- const probe = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/scm-connections/${item.id}/test`, { method: "POST", body: JSON.stringify({ repositoryUrl }) }) })
- const remove = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/scm-connections/${item.id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["connections", workspaceID] }) })
- return
{t(item.secretConfigured ? "connections.credentialConfigured" : "connections.publicAccess")}{open &&
}
+function RepositoryList({ items, loading = false, compact = false }: { items: Repository[]; loading?: boolean; compact?: boolean }) {
+ const { t } = useI18n()
+ if (loading) return
+ if (!items.length) return
+ return
+ {items.map((item) =>
{item.ref} · {item.currentSnapshot?.commitSha.slice(0, 12) || t("repositories.notSynced")}
)}
+
+}
+
+function RepositoryDetail({ item, workspaceID, navigate }: { item: Repository; workspaceID: string; navigate: (page: PageID, repositoryID?: string) => void }) {
+ const { formatDateTime, formatError, t } = useI18n()
+ const queryClient = useQueryClient()
+ const [tab, setTab] = useState<"activity" | "settings">("activity")
+ const [repositoryName, setRepositoryName] = useState(item.name)
+ const [repositoryRemoteUrl, setRepositoryRemoteUrl] = useState(item.remoteUrl)
+ const [repositoryRef, setRepositoryRef] = useState(item.ref)
+ const [providerConnectionId, setProviderConnectionId] = useState("")
+ const connections = usePagedQuery(["provider-connections"], "/me/provider-connections")
+ const activeConnections = (connections.data?.items ?? []).filter((connection) => connection.status === "active")
+ const operations = usePagedQuery(["repository-operations", item.id], `/workspaces/${workspaceID}/repositories/${item.id}/operations`, { refetchInterval: 4_000 })
+ const latestOperation = operations.data?.items[0]
+
+ const invalidateRepository = () => {
+ void queryClient.invalidateQueries({ queryKey: ["repositories", workspaceID] })
+ void queryClient.invalidateQueries({ queryKey: ["repository-operations", item.id] })
+ }
+ const refresh = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}/refresh`, {
+ method: "POST",
+ body: JSON.stringify(providerConnectionId ? { providerConnectionId } : {}),
+ }),
+ onSuccess: invalidateRepository,
+ })
+ const rename = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}`, { method: "PATCH", body: JSON.stringify({ name: repositoryName }) }),
+ onSuccess: invalidateRepository,
+ })
+ const archive = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}/${item.archivedAt ? "restore" : "archive"}`, { method: "POST", body: "{}" }),
+ onSuccess: invalidateRepository,
+ })
+ const remove = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}`, { method: "DELETE" }),
+ onSuccess: () => { invalidateRepository(); navigate("repositories") },
+ })
+ const updateSource = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/repositories/${item.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ remoteUrl: repositoryRemoteUrl,
+ ref: repositoryRef,
+ ...(providerConnectionId ? { providerConnectionId } : {}),
+ }),
+ }),
+ onSuccess: invalidateRepository,
+ })
+ const cancelOperation = useMutation({ mutationFn: (operationID: string) => api(`/workspaces/${workspaceID}/repositories/${item.id}/operations/${operationID}/cancel`, { method: "POST", body: "{}" }), onSuccess: invalidateRepository })
+ const mutationError = refresh.error ?? rename.error ?? archive.error ?? remove.error ?? updateSource.error ?? cancelOperation.error
+
+ return
+ {latestOperation && (latestOperation.status === "queued" || latestOperation.status === "running") ? : }
+ {item.currentSnapshot.commitSha} : t("repositories.notSynced")} mono />
+ {item.lastErrorMessage && {item.lastErrorMessage}
}
+ {mutationError && }
+
+ {tab === "activity" ? {operations.isLoading ? : operations.isError ? void operations.refetch()} />
: !operations.data?.items.length ?
: {operations.data.items.map((operation) => cancelOperation.mutate(operation.id)} />)}
} :
+
setRepositoryName(event.target.value)} />
+
+
+
}
+
}
-type ChannelForm = { type: string; name: string; clientId: string; clientSecret: string; senderAllowList: string; requireMention: boolean; prefix: string }
+function RepositoryOperationRow({ operation, onCancel }: { operation: RepositoryOperation; onCancel: () => void }) {
+ const { formatDateTime, t } = useI18n()
+ const active = operation.status === "queued" || operation.status === "running"
+ const kind = t(operation.kind === "provision" ? "repositories.operationProvision" : operation.kind === "refresh" ? "repositories.operationRefresh" : operation.kind === "update" ? "repositories.operationUpdate" : "repositories.operationPurge")
+ const outcome = operation.outcome ? t(operation.outcome === "changed" ? "repositories.outcomeChanged" : "repositories.outcomeNoChange") : ""
+ return
+
+ {kind}{outcome && {outcome}}{formatDateTime(operation.createdAt)}{operation.resolvedCommitSha ? ` · ${operation.resolvedCommitSha.slice(0, 12)}` : ""}{operation.errorMessage && {operation.errorMessage}}
+ {active && }
+
+}
+
+type ChannelForm = { type: string; name: string; clientId: string; clientSecret: string; targetId: string; robotCode: string; isLark: boolean; senderAllowList: string; requireMention: boolean; prefix: string; notificationEvents: ChannelNotificationEvent[] }
+
+const channelNotificationOptions = [
+ { event: "analysis.started", label: "channels.notifyStarted" },
+ { event: "analysis.succeeded", label: "channels.notifySucceeded" },
+ { event: "analysis.failed", label: "channels.notifyFailed" },
+] as const
+
+const defaultChannelNotificationEvents: ChannelNotificationEvent[] = channelNotificationOptions.map(({ event }) => event)
function Channels({ workspace }: { workspace: Workspace }) {
const { formatError, t } = useI18n()
const queryClient = useQueryClient()
const [adding, setAdding] = useState(false)
- const { register, handleSubmit, reset, control } = useForm({ defaultValues: { type: "feishu", name: "", clientId: "", clientSecret: "", senderAllowList: "*", requireMention: true, prefix: "/moon" } })
- const channelType = useWatch({ control, name: "type" })
- const channels = useQuery({ queryKey: ["channels", workspace.id], queryFn: () => api<{ items: ChannelInstance[] }>(`/workspaces/${workspace.id}/channels`), refetchInterval: 5_000 })
+ const [form, setForm] = useState({ type: "feishu", name: "", clientId: "", clientSecret: "", targetId: "", robotCode: "", isLark: false, senderAllowList: "*", requireMention: true, prefix: "/moon", notificationEvents: defaultChannelNotificationEvents })
+ const channels = usePagedQuery(["channels", workspace.id], `/workspaces/${workspace.id}/channels`, { refetchInterval: 5_000 })
const create = useMutation({
- mutationFn: (form: ChannelForm) => api(`/workspaces/${workspace.id}/channels`, {
+ mutationFn: () => api(`/workspaces/${workspace.id}/channels`, {
method: "POST",
body: JSON.stringify({
type: form.type,
name: form.name,
- values: form.type === "feishu" ? { app_id: form.clientId, is_lark: false } : { client_id: form.clientId },
+ values: form.type === "feishu"
+ ? { app_id: form.clientId, is_lark: form.isLark, receive_id: form.targetId, receive_id_type: "chat_id" }
+ : { client_id: form.clientId, robot_code: form.robotCode, open_conversation_id: form.targetId },
secrets: form.type === "feishu" ? { app_secret: form.clientSecret } : { client_secret: form.clientSecret },
senderAllowList: form.senderAllowList.split(",").map((value) => value.trim()).filter(Boolean),
groupPolicy: { requireMention: form.requireMention, prefix: form.prefix },
+ notificationEvents: form.notificationEvents,
}),
}),
- onSuccess: () => { setAdding(false); reset(); void queryClient.invalidateQueries({ queryKey: ["channels", workspace.id] }) },
+ onSuccess: () => {
+ setAdding(false)
+ setForm({ type: "feishu", name: "", clientId: "", clientSecret: "", targetId: "", robotCode: "", isLark: false, senderAllowList: "*", requireMention: true, prefix: "/moon", notificationEvents: defaultChannelNotificationEvents })
+ void queryClient.invalidateQueries({ queryKey: ["channels", workspace.id] })
+ },
})
- return setAdding((value) => !value)}>{t("channels.add")}} />
- {adding && }
- {channels.isLoading ?
: !channels.data?.items.length ?
:
{channels.data.items.map((item) => )}
}
+
+ return
+
setAdding(true)}>{t("channels.add")}} />
+ setAdding(false)}>
+
+
setForm({ ...form, name: event.target.value })} placeholder={t("channels.engineeringBot")} />
+
setForm({ ...form, clientId: event.target.value })} autoComplete="off" />
+
setForm({ ...form, clientSecret: event.target.value })} autoComplete="new-password" />
+ {form.type === "dingtalk" &&
setForm({ ...form, robotCode: event.target.value })} autoComplete="off" />}
+
setForm({ ...form, targetId: event.target.value })} autoComplete="off" />
+ {form.type === "feishu" &&
}
+
setForm({ ...form, senderAllowList: event.target.value })} />{t("channels.allowedSendersHelp")}
+
setForm({ ...form, prefix: event.target.value })} />
+
+
+ {create.isError &&
}
+
+
+
+ {channels.isLoading
+ ?
+ : channels.isError
+ ?
void channels.refetch()} />
+ : !channels.data?.items.length
+ ? setAdding(true)}>{t("channels.add")}} />
+ : {channels.data.items.map((item) => )}
}
+
+
}
function ChannelRow({ item, workspaceID }: { item: ChannelInstance; workspaceID: string }) {
- const { formatError, t } = useI18n()
- const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const [name, setName] = useState(item.name); const [allowList, setAllowList] = useState(item.config.senderAllowList.join(", ")); const [prefix, setPrefix] = useState(item.config.groupPolicy.prefix); const [requireMention, setRequireMention] = useState(item.config.groupPolicy.requireMention)
+ const { formatDateTime, formatError, t } = useI18n()
+ const queryClient = useQueryClient()
+ const [editing, setEditing] = useState(false)
+ const [name, setName] = useState(item.name)
+ const [allowList, setAllowList] = useState(item.config.senderAllowList.join(", "))
+ const [prefix, setPrefix] = useState(item.config.groupPolicy.prefix)
+ const [requireMention, setRequireMention] = useState(item.config.groupPolicy.requireMention)
+ const [notificationEvents, setNotificationEvents] = useState(item.notificationEvents)
+ const [replacementSecret, setReplacementSecret] = useState("")
const status = useQuery({ queryKey: ["channel-status", item.id], queryFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/status`), refetchInterval: item.enabled ? 5_000 : false })
- const toggle = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/${item.enabled ? "disable" : "enable"}`, { method: "POST", body: "{}" }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }); void queryClient.invalidateQueries({ queryKey: ["channel-status", item.id] }) } })
- const update = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}`, { method: "PATCH", body: JSON.stringify({ version: item.configVersion, name, senderAllowList: allowList.split(",").map((value) => value.trim()).filter(Boolean), groupPolicy: { prefix, requireMention } }) }), onSuccess: () => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) } })
+ const toggle = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/${item.enabled ? "disable" : "enable"}`, { method: "POST", body: "{}" }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] })
+ void queryClient.invalidateQueries({ queryKey: ["channel-status", item.id] })
+ },
+ })
+ const update = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({ version: item.configVersion, name, values: item.config.values, senderAllowList: allowList.split(",").map((value) => value.trim()).filter(Boolean), groupPolicy: { prefix, requireMention }, notificationEvents }),
+ }),
+ onSuccess: () => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) },
+ })
+ const rotateCredential = useMutation({
+ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}/credentials`, {
+ method: "PUT",
+ body: JSON.stringify({
+ version: item.configVersion,
+ secrets: item.type === "feishu" ? { app_secret: replacementSecret } : { client_secret: replacementSecret },
+ }),
+ }),
+ onSuccess: () => {
+ setReplacementSecret("")
+ void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] })
+ void queryClient.invalidateQueries({ queryKey: ["channel-status", item.id] })
+ },
+ })
const remove = useMutation({ mutationFn: () => api(`/workspaces/${workspaceID}/channels/${item.id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["channels", workspaceID] }) })
- const state = item.enabled ? status.data?.state ?? "starting" : "disabled"
- return
{item.type} · {t(item.secretConfigured ? "connections.credentialConfigured" : "channels.credentialMissing")}
{status.data?.lastErrorMessage &&
{status.data.lastErrorMessage}
}
{editing &&
}
+ const state = status.isError ? "error" : item.enabled ? status.data?.state ?? "starting" : "disabled"
+
+ return
+
+
+ {status.data?.lastErrorMessage && {status.data.lastErrorMessage}
}
+ {status.isError && {formatError(status.error)}
}
+ {toggle.isError && }
+ {item.enabled ? t("channels.receivingMessages") : t("channels.notReceivingMessages")}
+ setEditing(false)}>
+
}
function Messages({ workspace }: { workspace: Workspace }) {
const { t } = useI18n()
const [channelID, setChannelID] = useState("")
const [conversationID, setConversationID] = useState("")
- const channels = useQuery({ queryKey: ["channels", workspace.id], queryFn: () => api<{ items: ChannelInstance[] }>(`/workspaces/${workspace.id}/channels`) })
- const conversations = useQuery({ queryKey: ["conversations", workspace.id], queryFn: () => api<{ items: IMConversation[] }>(`/workspaces/${workspace.id}/conversations`) })
+ const channels = usePagedQuery(["channels", workspace.id], `/workspaces/${workspace.id}/channels`)
+ const conversations = usePagedQuery(["conversations", workspace.id], `/workspaces/${workspace.id}/conversations`)
const messages = useInfiniteQuery({
queryKey: ["messages", workspace.id, channelID, conversationID],
initialPageParam: "",
queryFn: ({ pageParam }) => {
- const query = new URLSearchParams({ pageSize: "30" })
+ const query = new URLSearchParams({ limit: "30" })
if (pageParam) query.set("cursor", pageParam)
if (channelID) query.set("channelId", channelID)
if (conversationID) query.set("conversationId", conversationID)
@@ -279,28 +1039,76 @@ function Messages({ workspace }: { workspace: Workspace }) {
})
const visibleConversations = (conversations.data?.items ?? []).filter((item) => !channelID || item.channelInstanceId === channelID)
const items = messages.data?.pages.flatMap((page) => page.items) ?? []
- return {messages.isLoading ?
: !items.length ?
: <>
{items.map((item) => )}
{messages.hasNextPage &&
}>}
+ const selectedConversation = conversations.data?.items.find((conversation) => conversation.id === conversationID)
+
+ return
+
+
+
+
{messages.isLoading ?
: messages.isError ?
void messages.refetch()} /> : !items.length ? : {items.map((item) => )}
}{messages.hasNextPage && }
+
+
}
function MessageRow({ item, compact = false }: { item: IMMessage; compact?: boolean }) {
const { formatDateTime, t } = useI18n()
- return {item.senderDisplayName || item.senderCanonicalId}{t("messages.via", { channel: item.channelName })}
{item.content.content.text || `[${item.content.content.type}]`}
+ if (!compact) return {(item.senderDisplayName || item.senderCanonicalId).slice(0, 1).toUpperCase()}{item.senderDisplayName || item.senderCanonicalId}{t("messages.via", { channel: item.channelName })} · {formatDateTime(item.occurredAt)}
{item.content.content.text || `[${item.content.content.type}]`}
+
+ return
+
+
{item.senderDisplayName || item.senderCanonicalId}{t("messages.via", { channel: item.channelName })}
+
+
+ {item.content.content.text || `[${item.content.content.type}]`}
+
}
-function WorkspaceSettings({ workspace }: { workspace: Workspace }) {
- const { formatDateTime, formatError, formatRole, t } = useI18n()
+function WorkspaceSettings({ workspace, userID, initialTab, navigate }: { workspace: Workspace; userID: string; initialTab: SettingsTab; navigate: (page: PageID, repositoryID?: string, settingsTab?: SettingsTab) => void }) {
+ const { formatDateTime, formatError, t } = useI18n()
const queryClient = useQueryClient()
const [name, setName] = useState(workspace.name)
+ const [reportRetentionDays, setReportRetentionDays] = useState(workspace.reportRetentionDays)
const [newWorkspace, setNewWorkspace] = useState("")
const [inviteEmail, setInviteEmail] = useState("")
- const [inviteRole, setInviteRole] = useState<"admin" | "member" | "viewer">("member")
const [invitationLink, setInvitationLink] = useState("")
- const members = useQuery({ queryKey: ["workspace-members", workspace.id], queryFn: () => api<{ items: WorkspaceMember[] }>(`/workspaces/${workspace.id}/members`) })
- const invitations = useQuery({ queryKey: ["workspace-invitations", workspace.id], queryFn: () => api<{ items: WorkspaceInvitation[] }>(`/workspaces/${workspace.id}/invitations`) })
- const rename = useMutation({ mutationFn: () => api(`/workspaces/${workspace.id}`, { method: "PATCH", body: JSON.stringify({ name }) }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["session"] }) })
- const create = useMutation({ mutationFn: () => api("/workspaces", { method: "POST", body: JSON.stringify({ name: newWorkspace }) }), onSuccess: () => { setNewWorkspace(""); void queryClient.invalidateQueries({ queryKey: ["session"] }) } })
+ const canManage = workspace.role === "owner" || workspace.role === "admin"
+ const members = usePagedQuery(["workspace-members", workspace.id], `/workspaces/${workspace.id}/members`)
+ const invitations = usePagedQuery(["workspace-invitations", workspace.id], `/workspaces/${workspace.id}/invitations`, { enabled: canManage })
+ const updateWorkspace = useMutation({
+ mutationFn: () => api(`/workspaces/${workspace.id}`, { method: "PATCH", body: JSON.stringify({ name, reportRetentionDays }) }),
+ onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["session"] }),
+ })
+ const create = useMutation({
+ mutationFn: () => api("/workspaces", { method: "POST", body: JSON.stringify({ name: newWorkspace }) }),
+ onSuccess: () => { setNewWorkspace(""); void queryClient.invalidateQueries({ queryKey: ["session"] }) },
+ })
const invite = useMutation({
- mutationFn: () => api(`/workspaces/${workspace.id}/invitations`, { method: "POST", body: JSON.stringify({ email: inviteEmail, role: inviteRole }) }),
+ mutationFn: () => api(`/workspaces/${workspace.id}/invitations`, { method: "POST", body: JSON.stringify({ email: inviteEmail }) }),
onSuccess: (created) => {
const link = new URL("/app", window.location.origin)
link.hash = new URLSearchParams({ invitation: created.token ?? "" }).toString()
@@ -311,25 +1119,60 @@ function WorkspaceSettings({ workspace }: { workspace: Workspace }) {
})
const revokeInvitation = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspace.id}/invitations/${id}`, { method: "DELETE" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["workspace-invitations", workspace.id] }) })
const removeMember = useMutation({ mutationFn: (id: string) => api(`/workspaces/${workspace.id}/members/${id}`, { method: "DELETE", body: "{}" }), onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["workspace-members", workspace.id] }) })
- return
-
+
+ return
+
} />
+
navigate("settings", undefined, tab)} ariaLabel={t("settings.tabs")} options={[{ value: "workspace", label: t("settings.workspaceTab") }, { value: "team", label: t("settings.teamTab") }, { value: "credentials", label: t("settings.credentialsTab") }]} />
+ {initialTab === "credentials" &&
}
+ {initialTab === "workspace" && <>
+
- {(workspace.role === "owner" || workspace.role === "admin") &&
+ >}
+ {initialTab === "team" && <>
+ {canManage ?
{t("settings.invitations")}
{t("settings.invitationsDescription")}
- setInviteEmail(event.target.value)} placeholder="person@example.com" />
+ setInviteEmail(event.target.value)} placeholder="person@example.com" />
{invite.isError && }
- {invitationLink && }
- {invitations.isLoading ?
: invitations.data?.items.map((invitation) =>
{invitation.email}
{invitation.acceptedAt ? t("settings.accepted") : invitation.revokedAt ? t("settings.revoked") : new Date(invitation.expiresAt) <= new Date() ? t("settings.expired") : t("settings.expires", { date: formatDateTime(invitation.expiresAt) })}
{formatRole(invitation.role)}{!invitation.acceptedAt && !invitation.revokedAt &&
}
)}
- }
+ {invitationLink && }
+
+ {invitations.isLoading ?
: invitations.isError ?
void invitations.refetch()} /> : !invitations.data?.items.length ? {t("settings.noInvitations")}
: invitations.data.items.map((invitation) =>
+
{invitation.email}
{invitation.acceptedAt ? t("settings.accepted") : invitation.revokedAt ? t("settings.revoked") : new Date(invitation.expiresAt) <= new Date() ? t("settings.expired") : t("settings.expires", { date: formatDateTime(invitation.expiresAt) })}
+ {!invitation.acceptedAt && !invitation.revokedAt &&
}
+
)}
+
+
+ {revokeInvitation.isError && }
+ :
}
{t("settings.members")}
- {members.isLoading ?
: members.data?.items.map((member) => { const removable = member.userId !== workspace.createdBy && (workspace.role === "owner" || (workspace.role === "admin" && member.role !== "owner" && member.role !== "admin")); return
{(member.displayName || member.username || "U").slice(0, 1).toUpperCase()}
{member.displayName || member.username}
{member.email || member.userId}
{formatRole(member.role)}{removable &&
}
})}
+
+ {members.isLoading ?
: members.isError ?
void members.refetch()} /> : !members.data?.items.length ? {t("settings.noMembers")}
: members.data.items.map((member) => {
+ const canRemove = canManage && member.role !== "owner" && member.userId !== userID && (workspace.role === "owner" || member.role === "member")
+ return
+
{(member.displayName || member.username || "U").slice(0, 1).toUpperCase()}
+
{member.displayName || member.username}
{member.email || member.userId}
+
{t(member.role === "owner" ? "settings.roleOwner" : member.role === "admin" ? "settings.roleAdmin" : "settings.roleMember")}
+ {canRemove &&
}
+
})}
+
+
+ {removeMember.isError && }
-
+ >}
}
diff --git a/frontend/src/app/Authentication.tsx b/frontend/src/app/Authentication.tsx
index 4e08b0a..6d24f30 100644
--- a/frontend/src/app/Authentication.tsx
+++ b/frontend/src/app/Authentication.tsx
@@ -8,18 +8,23 @@ import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher"
import { BrandMark, FormError } from "@/components/shared/presentation"
import { useI18n } from "@/i18n/context"
import { api, APIError } from "@/lib/api/client"
+import { logout, oauthRedirectURL } from "@/lib/auth"
import type { Session } from "@/lib/api/types"
export function AuthenticationFailure({ error, invitationToken }: { error: unknown; invitationToken: string }) {
const { formatError, t } = useI18n()
const failure = error instanceof APIError ? error : null
+ const login = useMutation({
+ mutationFn: oauthRedirectURL,
+ onSuccess: (redirectURL) => window.location.assign(redirectURL),
+ })
if (failure?.code === "account.suspended") {
return
}
if (failure?.code === "registration.closed") {
return
}
- if (failure?.status !== 401 || !failure.loginURL) {
+ if (failure?.status !== 401) {
return
}
return (
@@ -30,7 +35,8 @@ export function AuthenticationFailure({ error, invitationToken }: { error: unkno
? t("auth.invitation.signInDescription")
: t("auth.login.description")}
>
-
}
-
+ signOut.mutate()}>
{t("common.signOut")}
-
+
)
}
diff --git a/frontend/src/app/AuthenticationContinuation.tsx b/frontend/src/app/AuthenticationContinuation.tsx
new file mode 100644
index 0000000..d80d046
--- /dev/null
+++ b/frontend/src/app/AuthenticationContinuation.tsx
@@ -0,0 +1,14 @@
+import { useEffect } from "react"
+
+import { PageLoading } from "@/components/shared/presentation"
+import { postLoginRedirectURL } from "@/lib/auth"
+
+export function AuthenticationContinuation() {
+ useEffect(() => {
+ const redirectURI = new URLSearchParams(window.location.search).get("redirect_uri")
+
+ window.location.replace(postLoginRedirectURL(redirectURI))
+ }, [])
+
+ return
+}
diff --git a/frontend/src/components/shared/presentation.tsx b/frontend/src/components/shared/presentation.tsx
index c8d846c..72408c4 100644
--- a/frontend/src/components/shared/presentation.tsx
+++ b/frontend/src/components/shared/presentation.tsx
@@ -1,12 +1,13 @@
+import { useEffect, useRef } from "react"
import type { LucideIcon } from "lucide-react"
-import { LoaderCircle } from "lucide-react"
+import { AlertTriangle, LoaderCircle, X } from "lucide-react"
import { LanguageSwitcher } from "@/components/shared/LanguageSwitcher"
import { cn } from "@/lib/utils"
import { useI18n } from "@/i18n/context"
export function PageTitle({ title, description, action }: { title: string; description: string; action?: React.ReactNode }) {
- return
+ return
}
export function Field({ label, wide = false, children }: { label: string; wide?: boolean; children: React.ReactNode }) {
@@ -15,15 +16,114 @@ export function Field({ label, wide = false, children }: { label: string; wide?:
export function StateBadge({ state }: { state: string }) {
const { formatState } = useI18n()
- return {formatState(state)}
+ const tone = state === "ready" || state === "connected" || state === "succeeded"
+ ? "bg-emerald-50 text-emerald-700"
+ : state === "failed" || state === "error" || state === "invalid"
+ ? "bg-red-50 text-red-700"
+ : state === "archived" || state === "disabled" || state === "cancelled" || state === "revoked"
+ ? "bg-slate-100 text-slate-600"
+ : "bg-blue-50 text-blue-700"
+ return {formatState(state)}
}
-export function Metric({ label, value }: { label: string; value: string }) {
- return
+export function Metric({ label, value, className }: { label: string; value: string; className?: string }) {
+ return
}
-export function EmptyState({ icon: Icon, title, description }: { icon: LucideIcon; title: string; description: string }) {
- return
+export function EmptyState({ icon: Icon, title, description, action }: { icon: LucideIcon; title: string; description: string; action?: React.ReactNode }) {
+ return {title}
{description}
{action &&
{action}
}
+}
+
+export function ErrorState({ error, onRetry }: { error: unknown; onRetry?: () => void }) {
+ const { formatError, t } = useI18n()
+ return {t("common.requestFailed")}
{formatError(error)}
{onRetry &&
{t("common.retry")}}
+}
+
+export function Panel({ className, children }: { className?: string; children: React.ReactNode }) {
+ return
+}
+
+export function SectionHeader({ title, description, action, className }: { title: string; description?: string; action?: React.ReactNode; className?: string }) {
+ return {title}
{description &&
{description}
}
{action &&
{action}
}
+}
+
+export function SegmentedControl({ value, options, onChange, ariaLabel }: { value: T; options: Array<{ value: T; label: string; count?: number }>; onChange: (value: T) => void; ariaLabel: string }) {
+ return
+ {options.map((option) => onChange(option.value)}>{option.label}{option.count !== undefined && {option.count}})}
+
+}
+
+export function DefinitionItem({ label, value, mono = false, className }: { label: string; value: React.ReactNode; mono?: boolean; className?: string }) {
+ return {label}{value}
+}
+
+export function Drawer({ open, title, description, onClose, children, footer }: { open: boolean; title: string; description?: string; onClose: () => void; children: React.ReactNode; footer?: React.ReactNode }) {
+ const { t } = useI18n()
+ const dialogRef = useRef(null)
+ const onCloseRef = useRef(onClose)
+
+ useEffect(() => {
+ onCloseRef.current = onClose
+ }, [onClose])
+
+ useEffect(() => {
+ if (!open) return
+
+ const previousOverflow = document.body.style.overflow
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
+ const focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ const focusDialog = window.requestAnimationFrame(() => {
+ const firstFocusable = dialogRef.current?.querySelector(focusableSelector)
+ const focusTarget = firstFocusable ?? dialogRef.current
+ focusTarget?.focus()
+ })
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ onCloseRef.current()
+ return
+ }
+ if (event.key !== "Tab" || !dialogRef.current) return
+
+ const focusable = Array.from(dialogRef.current.querySelectorAll(focusableSelector))
+ .filter((element) => element.getClientRects().length > 0)
+ if (!focusable.length) {
+ event.preventDefault()
+ dialogRef.current.focus()
+ return
+ }
+
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ const active = document.activeElement
+ if (event.shiftKey && (active === first || !dialogRef.current.contains(active))) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && active === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+ document.body.style.overflow = "hidden"
+ window.addEventListener("keydown", handleKeyDown)
+
+ return () => {
+ window.cancelAnimationFrame(focusDialog)
+ document.body.style.overflow = previousOverflow
+ window.removeEventListener("keydown", handleKeyDown)
+ previousFocus?.focus()
+ }
+ }, [open])
+
+ if (!open) return null
+
+ return
+
+
+ {title}
{description &&
{description}
}
+ {children}
+ {footer && }
+
+
}
export function FormError({ message }: { message: string }) {
diff --git a/frontend/src/i18n/I18nProvider.tsx b/frontend/src/i18n/I18nProvider.tsx
index 770b536..0927bde 100644
--- a/frontend/src/i18n/I18nProvider.tsx
+++ b/frontend/src/i18n/I18nProvider.tsx
@@ -6,7 +6,7 @@ import type { TranslationKey } from "@/i18n/catalog"
import { I18nContext } from "@/i18n/context"
import type { I18nContextValue } from "@/i18n/context"
import { syncDocumentLocale } from "@/i18n/document"
-import { formatDateTime as formatLocalizedDateTime, formatError as formatLocalizedError, formatRole as formatLocalizedRole, formatState as formatLocalizedState, translate } from "@/i18n/format"
+import { formatDateTime as formatLocalizedDateTime, formatError as formatLocalizedError, formatState as formatLocalizedState, translate } from "@/i18n/format"
import type { TranslationParams } from "@/i18n/format"
import { detectLocale, persistLocale } from "@/i18n/locale"
import type { Locale } from "@/i18n/locale"
@@ -32,10 +32,6 @@ export function I18nProvider({ children }: { children: ReactNode }) {
return formatLocalizedState(catalog, state)
}, [catalog])
- const formatRole = useCallback((role: string) => {
- return formatLocalizedRole(catalog, role)
- }, [catalog])
-
const formatError = useCallback((error: unknown) => {
return formatLocalizedError(catalog, error)
}, [catalog])
@@ -50,9 +46,8 @@ export function I18nProvider({ children }: { children: ReactNode }) {
t,
formatDateTime,
formatState,
- formatRole,
formatError,
- }), [formatDateTime, formatError, formatRole, formatState, locale, setLocale, t])
+ }), [formatDateTime, formatError, formatState, locale, setLocale, t])
return {children}
}
diff --git a/frontend/src/i18n/catalog.ts b/frontend/src/i18n/catalog.ts
index 9b97bf5..58864c6 100644
--- a/frontend/src/i18n/catalog.ts
+++ b/frontend/src/i18n/catalog.ts
@@ -10,17 +10,24 @@ export const en = {
"common.provider": "Provider",
"common.platform": "Platform",
"common.cancel": "Cancel",
+ "common.retry": "Retry",
+ "common.archive": "Archive",
+ "common.restore": "Restore",
"common.save": "Save",
"common.test": "Test",
"common.copy": "Copy",
+ "common.copied": "Copied",
+ "common.copyFailed": "Copy failed",
"common.delete": "Delete",
"common.close": "Close",
"common.enabled": "Enabled",
"common.disabled": "Disabled",
"common.loading": "Loading",
+ "common.never": "Never",
"common.requestFailed": "Request failed",
"common.viewAll": "View all",
"common.loadOlder": "Load older messages",
+ "common.loadMore": "Load more",
"common.signOut": "Sign out",
"common.workspace": "Workspace",
@@ -42,10 +49,10 @@ export const en = {
"landing.capability.assessment.description": "Continuously surface quality risks so AI-generated code meets the same engineering standards as any production code.",
"landing.capability.messaging.title": "Built into team communication",
"landing.capability.messaging.description": "Work with MoonCode through Feishu, DingTalk, and the tools your team already knows, bringing quality feedback into daily collaboration.",
- "landing.capability.saas.title": "Multi-tenant SaaS",
- "landing.capability.saas.description": "Keep organizations, members, and data isolated by Workspace with explicit identity and role authorization boundaries.",
+ "landing.capability.saas.title": "Workspace collaboration",
+ "landing.capability.saas.description": "Organize repositories, members, analyses, and team channels within clear Workspace data boundaries.",
"landing.capability.source.title": "Connect your source",
- "landing.capability.source.description": "Sync from GitHub, GitLab, and generic Git services, pinning every assessment to an exact commit.",
+ "landing.capability.source.description": "Sync from GitHub and GitLab, pinning every assessment to an exact commit.",
"landing.workflow.eyebrow": "How it works",
"landing.workflow.title": "From one commit to continuous improvement.",
"landing.workflow.description": "Make code quality an everyday engineering signal instead of a one-time check before release.",
@@ -58,8 +65,8 @@ export const en = {
"landing.foundation.eyebrow": "Built for teams",
"landing.foundation.title": "Connect existing tools. Protect every Workspace boundary.",
"landing.foundation.auth": "GitHub OAuth login with authentication handled by tinyauth",
- "landing.foundation.authorization": "Workspace data isolation with Owner, Admin, Member, and Viewer roles",
- "landing.foundation.secrets": "Encrypted Git and IM credentials that are never returned by the API",
+ "landing.foundation.authorization": "Workspace membership boundaries keep collaborative resources isolated",
+ "landing.foundation.secrets": "Personal SCM credentials and Workspace IM credentials are encrypted and never returned by the API",
"landing.cta.title": "Let code quality keep pace with AI.",
"landing.cta.description": "Start with your first repository and build a continuous, transparent quality feedback loop for your team.",
"landing.cta.action": "Open MoonCode",
@@ -82,7 +89,7 @@ export const en = {
"landing.preview.caption": "Product interface concept · Assessment capabilities will evolve with the platform",
"auth.accountSuspended.title": "Account suspended",
- "auth.accountSuspended.description": "Your MoonCode account is currently suspended. Contact the workspace administrator if you believe this is a mistake.",
+ "auth.accountSuspended.description": "Your MoonCode account is currently suspended. Contact the deployment operator if you believe this is a mistake.",
"auth.registrationClosed.title": "Registration is closed",
"auth.registrationClosed.description": "This MoonCode deployment is not accepting new accounts.",
"auth.unavailable.title": "MoonCode is unavailable",
@@ -101,7 +108,7 @@ export const en = {
"auth.setup.createTitle": "Create your MoonCode account",
"auth.invitation.requiredTitle": "An invitation is required",
"auth.invitation.signedIn": "Signed in as {email}. Confirm the policies to join the workspace.",
- "auth.setup.welcome": "Welcome, {name}. Confirm the policies to create your personal workspace.",
+ "auth.setup.welcome": "Welcome, {name}. Confirm the policies to activate your account.",
"auth.invitation.requiredDescription": "You are authenticated, but this deployment only activates accounts through a workspace invitation.",
"auth.acceptTerms": "I accept Terms {version}",
"auth.acceptPrivacy": "I accept Privacy Policy {version}",
@@ -111,9 +118,12 @@ export const en = {
"nav.toggle": "Toggle navigation",
"nav.workspace": "Workspace",
+ "nav.account": "Account settings",
+ "nav.qualityWorkflow": "Quality workflow",
+ "nav.teamCollaboration": "Team collaboration",
"nav.overview": "Overview",
"nav.repositories": "Repositories",
- "nav.connections": "Connections",
+ "nav.analysis": "Quality analysis",
"nav.channels": "Channels",
"nav.messages": "Messages",
"nav.settings": "Settings",
@@ -127,58 +137,159 @@ export const en = {
"overview.heroTitle": "Build a reliable foundation for AI code quality.",
"overview.heroDescription": "Connect a Git repository and Feishu or DingTalk. MoonCode pins assessments to exact commits and brings your team into one quality workflow.",
"overview.repositories": "Repositories",
+ "overview.withoutCode": "Without synchronized code",
+ "overview.failedSyncs": "Recent failed syncs",
+ "overview.activeAnalyses": "Active analyses",
"overview.activeChannels": "Active channels",
"overview.recentMessages": "Recent messages",
"overview.failedJobs": "Failed jobs",
"overview.recentRepositories": "Recent repositories",
"overview.noMessages": "No messages",
"overview.noMessagesDescription": "Connected channels will publish inbound messages here.",
+ "overview.attentionTitle": "Needs attention",
+ "overview.attentionDescription": "{count} items need your attention.",
+ "overview.allClear": "Your quality workflow is healthy.",
+ "overview.failedSyncAction": "Review failed repository synchronization attempts.",
+ "overview.unsyncedAction": "Synchronize repositories that do not have a commit snapshot.",
+ "overview.noAttention": "Everything is ready. No repository or workflow issues need attention.",
+ "overview.setupTitle": "Workspace setup",
+ "overview.setupProgress": "{completed} of {total} essential steps complete",
+ "overview.setupRepository": "Add your first repository",
+ "overview.setupSync": "Synchronize a commit snapshot",
+ "overview.setupChannel": "Connect a team message channel",
+ "overview.synchronized": "Synchronized",
"repositories.title": "Repositories",
"repositories.description": "Keep one explicitly synchronized commit for each repository.",
"repositories.add": "Add repository",
- "repositories.cloneURL": "Clone URL",
- "repositories.refOptional": "Branch, tag, or commit (optional)",
- "repositories.genericHTTPS": "Generic Git HTTPS",
- "repositories.connectAndSync": "Connect and sync",
+ "repositories.addAndSync": "Add and sync",
"repositories.empty": "No repositories",
"repositories.emptyDescription": "Connect a repository to synchronize its current source.",
"repositories.cancelSync": "Cancel synchronization",
"repositories.sync": "Synchronize",
"repositories.delete": "Delete repository",
"repositories.deleteConfirm": "Delete {name} and its local source?",
- "repositories.syncRefPrompt": "Branch, tag, or commit to synchronize. Leave empty to use the remote default.",
- "repositories.remoteDefault": "remote default",
"repositories.notSynced": "not synchronized",
+ "repositories.remoteUrl": "Repository URL",
+ "repositories.rename": "Rename repository",
+ "repositories.ref": "Branch, tag, or commit (ref)",
+ "repositories.nameOptional": "Name (optional)",
+ "repositories.namePlaceholder": "Derived from repository URL when empty",
+ "repositories.credentialOptional": "Personal PAT (optional)",
+ "repositories.publicAccess": "No credential (public repository)",
+ "repositories.chooseRef": "A branch and tag share this name. Choose which ref to track.",
+ "repositories.syncHistory": "Synchronization history",
+ "repositories.targetExample": "main / v1.0.0 / commit SHA",
+ "repositories.changeTarget": "Change target",
+ "repositories.saveTarget": "Save and sync",
+ "repositories.searchPlaceholder": "Search repositories",
+ "repositories.noSearchResults": "No repositories match your search.",
+ "repositories.createDescription": "Enter a Git repository and ref. MoonCode will create its managed mirror and synchronize the current commit.",
+ "repositories.analyze": "Quality analysis",
+ "repositories.currentCommit": "Current commit",
+ "repositories.trackedRef": "Tracked ref",
+ "repositories.lastSync": "Last synchronized",
+ "repositories.mirrorSize": "Mirror size",
+ "repositories.detailTabs": "Repository detail sections",
+ "repositories.activity": "Activity",
+ "repositories.syncHistoryDescription": "Repository synchronization and source update activity.",
+ "repositories.noActivity": "No repository activity",
+ "repositories.noActivityDescription": "Synchronization operations will appear here.",
+ "repositories.generalSettings": "General settings",
+ "repositories.generalSettingsDescription": "Change the display name used within this workspace.",
+ "repositories.sourceSettings": "Repository source",
+ "repositories.sourceSettingsDescription": "Changing the URL or ref creates and synchronizes a new current commit snapshot.",
+ "repositories.dangerZone": "Danger zone",
+ "repositories.dangerZoneDescription": "Archive this repository or permanently remove its managed mirror and history.",
+ "repositories.copyCommit": "Copy commit SHA",
+ "repositories.operationProvision": "Initial synchronization",
+ "repositories.operationRefresh": "Synchronize ref",
+ "repositories.operationUpdate": "Update repository source",
+ "repositories.operationPurge": "Remove repository data",
+ "repositories.outcomeChanged": "new commit",
+ "repositories.outcomeNoChange": "already current",
- "connections.title": "Connections",
- "connections.description": "Credentials are encrypted and are never returned by the API.",
- "connections.new": "New connection",
- "connections.baseURL": "Base URL",
- "connections.username": "Username",
- "connections.accessToken": "Access token",
- "connections.genericGit": "Generic Git",
- "connections.save": "Save connection",
- "connections.empty": "No connections",
- "connections.emptyDescription": "Add credentials for private Git repositories.",
- "connections.credentialConfigured": "Credential configured",
- "connections.publicAccess": "Public access",
- "connections.delete": "Delete connection",
- "connections.deleteConfirm": "Delete {name}?",
- "connections.displayName": "Display name",
- "connections.replaceToken": "Replace access token",
- "connections.keepToken": "Leave blank to keep current",
- "connections.repositoryURL": "Repository URL",
- "connections.succeeded": "Connection succeeded.",
+ "providerConnections.title": "Provider connections",
+ "providerConnections.description": "Manage your private GitHub and GitLab PATs. A connection is only available to your user account.",
+ "providerConnections.add": "Add provider connection",
+ "providerConnections.baseUrl": "Base URL",
+ "providerConnections.token": "Personal access token",
+ "providerConnections.privateHint": "Private to your user account and never shared with a workspace",
+ "providerConnections.default": "default",
+ "providerConnections.makeDefault": "Make default",
+ "providerConnections.rotate": "Replace PAT",
+ "providerConnections.newToken": "New personal access token",
+ "providerConnections.rotateSave": "Validate and replace",
+ "providerConnections.deleteConfirm": "Delete provider connection {name}?",
+ "providerConnections.testSucceeded": "Connection verified successfully.",
+
+ "analysis.run": "Analyze snapshot",
+ "analysis.snapshot": "Commit snapshot",
+ "analysis.profile": "Analysis profile",
+ "analysis.profileOption": "{name} · v{version}",
+ "analysis.profiles": "Analysis profiles",
+ "analysis.profilesDescription": "Versioned analyzer settings frozen into every run.",
+ "analysis.addProfile": "Add profile",
+ "analysis.timeoutSeconds": "Timeout (seconds)",
+ "analysis.saveProfile": "Save profile",
+ "analysis.createVersion": "Create version",
+ "analysis.profileMetadata": "v{version} · {timeout}s timeout · scc",
+ "analysis.title": "Quality analysis",
+ "analysis.description": "Analyze exact repository snapshots across this workspace.",
+ "analysis.empty": "No repositories to analyze",
+ "analysis.emptyDescription": "Add and synchronize a repository before running quality analysis.",
+ "analysis.noSnapshot": "Synchronize this repository before running analysis.",
+ "analysis.codeScale": "Code scale",
+ "analysis.none": "No analysis has been run for this snapshot.",
+ "analysis.summary": "{files} files · {code} code lines · {languages} languages",
+ "analysis.manageProfiles": "Manage profiles",
+ "analysis.chooseRepository": "Choose repository",
+ "analysis.stepSnapshot": "1. Commit snapshot",
+ "analysis.stepProfile": "2. Analysis profile",
+ "analysis.noSnapshotTitle": "No commit snapshot",
+ "analysis.noProfilesTitle": "No analysis profile",
+ "analysis.noProfilesDescription": "Create a profile before running the repository analysis.",
+ "analysis.metricFiles": "Files",
+ "analysis.metricCode": "Lines of code",
+ "analysis.metricLanguages": "Languages",
+ "analysis.metricComplexity": "Complexity",
+ "analysis.languageDistribution": "Language distribution",
+ "analysis.reportMetadata": "Commit {commit} · completed in {duration} ms",
+ "analysis.languageFiles": "{count} files",
+ "analysis.noReportTitle": "Ready to analyze",
+ "analysis.runHistory": "Run history",
+ "analysis.reportTitle": "Analysis report",
+ "analysis.reportSelection": "Commit {commit} · profile {profile}",
+ "analysis.backToSelection": "Back to current selection",
+ "analysis.runStatus": "Analysis in progress",
+ "analysis.runStatusDescription": "Stage: {stage} · attempt {attempt}",
+ "analysis.runHistoryDescription": "Open any previous run to inspect its status or report.",
+ "analysis.attempt": "attempt {attempt}",
+ "analysis.viewReport": "View report",
+ "analysis.metricLines": "Total lines",
+ "analysis.metricComments": "Comment lines",
+ "analysis.metricBlanks": "Blank lines",
+ "analysis.metricBytes": "Source size",
+ "analysis.metricDuration": "Duration",
+ "analysis.metricAnalyzer": "Analyzer",
+ "analysis.languageShare": "{code} · {percentage}%",
+ "analysis.warnings": "Analyzer warnings",
+ "analysis.warningsDescription": "The analyzer completed, but reported the following conditions.",
"channels.title": "Channels",
"channels.description": "Receive team messages through managed long-lived connections.",
"channels.add": "Add channel",
+ "channelLink.linking": "Linking your chat identity…",
+ "channelLink.success": "Your chat identity is now linked to this MoonCode user.",
"channels.engineeringBot": "Engineering bot",
"channels.appID": "App ID",
"channels.clientID": "Client ID",
"channels.appSecret": "App secret",
"channels.clientSecret": "Client secret",
+ "channels.robotCode": "Robot code",
+ "channels.chatID": "Target chat ID",
+ "channels.conversationID": "Open conversation ID",
+ "channels.useLark": "Use Lark international endpoints",
"channels.allowedSenders": "Allowed sender IDs",
"channels.allowedSendersHelp": "Comma-separated; use * only when every sender is trusted.",
"channels.groupPrefix": "Group prefix",
@@ -194,6 +305,21 @@ export const en = {
"channels.deleteConfirm": "Delete {name}?",
"channels.requireMentionShort": "Require mention or prefix",
"channels.saveChanges": "Save changes",
+ "channels.replacementSecret": "Enter a new secret",
+ "channels.rotateCredential": "Replace credential",
+ "channels.notifications": "Analysis notifications",
+ "channels.notifyStarted": "Started",
+ "channels.notifySucceeded": "Succeeded",
+ "channels.notifyFailed": "Failed",
+ "channels.createDescription": "Connect a Feishu, Lark, or DingTalk application for inbound messages and analysis notifications.",
+ "channels.connectionStatus": "Connection",
+ "channels.lastConnected": "Last connected",
+ "channels.credential": "Credential",
+ "channels.configured": "Configured",
+ "channels.receivingMessages": "Receiving messages",
+ "channels.notReceivingMessages": "Message reception is paused",
+ "channels.configureTitle": "Configure {name}",
+ "channels.configureDescription": "Update message access, notifications, and channel credentials.",
"messages.title": "Messages",
"messages.description": "Normalized inbound messages received from connected channels.",
@@ -204,17 +330,27 @@ export const en = {
"messages.empty": "No messages",
"messages.emptyDescription": "Incoming Feishu and DingTalk messages will appear here.",
"messages.via": "via {channel}",
+ "messages.channels": "Channels",
+ "messages.conversations": "Conversations",
+ "messages.noChannels": "No channels configured.",
+ "messages.noConversations": "No conversations yet.",
+ "messages.allMessages": "All messages",
+ "messages.conversationVia": "Conversation through {channel}",
+ "messages.streamDescription": "Messages across the selected channels and conversations.",
- "settings.title": "Workspace settings",
- "settings.description": "Manage the workspace and invite people by verified GitHub email.",
+ "settings.title": "Settings",
+ "settings.description": "Manage your personal provider connections and this workspace.",
"settings.general": "General",
"settings.workspaceName": "Workspace name",
+ "settings.reportRetentionDays": "Analysis retention (days)",
+ "settings.reportRetentionDescription": "Completed analysis runs and reports are removed after this period. Active runs are never removed.",
"settings.invitations": "Invitations",
"settings.invitationsDescription": "The recipient must sign in with the verified GitHub email entered here.",
"settings.email": "Email",
- "settings.role": "Role",
"settings.createInvitation": "Create invitation",
"settings.copyInvitation": "Copy this link now. The token is only returned once.",
+ "settings.noInvitations": "No pending or historical invitations.",
+ "settings.noMembers": "No workspace members found.",
"settings.accepted": "Accepted",
"settings.revoked": "Revoked",
"settings.expired": "Expired",
@@ -225,11 +361,19 @@ export const en = {
"settings.createWorkspace": "Create another workspace",
"settings.create": "Create",
"settings.emptyModule": "{title} is ready for the next product slice.",
+ "settings.tabs": "Settings sections",
+ "settings.workspaceTab": "Workspace",
+ "settings.teamTab": "Team members & invitations",
+ "settings.credentialsTab": "Personal code credentials",
+ "settings.createWorkspaceDescription": "Create a separate workspace with its own repositories, members, and channels.",
+ "settings.workspaceManageDescription": "Manage this workspace's identity and analysis report retention.",
+ "settings.workspaceReadOnlyDescription": "Only workspace owners and administrators can change these settings.",
+ "settings.adminOnlyDescription": "Only workspace owners and administrators can create or revoke invitations.",
+ "settings.roleOwner": "Owner",
+ "settings.roleAdmin": "Administrator",
+ "settings.roleMember": "Member",
+ "settings.removeMemberConfirm": "Remove {name} from this workspace?",
- "role.owner": "Owner",
- "role.admin": "Admin",
- "role.member": "Member",
- "role.viewer": "Viewer",
"state.pending": "Pending",
"state.queued": "Queued",
"state.active": "Active",
@@ -241,6 +385,7 @@ export const en = {
"state.running": "Running",
"state.connected": "Connected",
"state.disabled": "Disabled",
+ "state.error": "Error",
"state.uploading": "Uploading",
"state.deleting": "Deleting",
"state.cancelled": "Cancelled",
@@ -248,6 +393,9 @@ export const en = {
"state.retry_wait": "Waiting to retry",
"state.suspended": "Suspended",
"state.deleted": "Deleted",
+ "state.owner": "Owner",
+ "state.admin": "Administrator",
+ "state.member": "Member",
"error.http.request_failed": "Request failed.",
"error.request.invalid_json": "The request body is invalid.",
@@ -280,8 +428,26 @@ export const en = {
"error.workspace.list_failed": "Unable to list workspaces.",
"error.workspace.create_failed": "Unable to create the workspace.",
"error.workspace.update_failed": "Unable to update the workspace.",
+ "error.workspace.update_required": "Change at least one workspace setting.",
+ "error.workspace.name_required": "The workspace name is required.",
+ "error.workspace.report_retention_invalid": "Analysis retention must be between 1 and 3650 days.",
"error.workspace.members_failed": "Unable to load workspace members.",
"error.workspace.member_failed": "Unable to update the workspace member.",
+ "error.workspace.last_member": "A workspace must keep at least one member.",
+ "error.workspace.forbidden": "You do not have permission to access this workspace.",
+ "error.workspace.invitation_email_invalid": "Enter a valid invitation email address.",
+ "error.workspace.invitation_not_found": "The workspace invitation was not found.",
+ "error.workspace.invitation_recipient_mismatch": "This invitation belongs to a different account.",
+ "error.workspace.invitation_unavailable": "The workspace invitation is no longer available.",
+ "error.workspace.member_not_removable": "This workspace member cannot be removed.",
+ "error.account.registration_closed": "Registration is not currently open.",
+ "error.account.terms_required": "Accept the terms and privacy policy to continue.",
+ "error.auth.identity_failed": "Unable to verify your authenticated identity.",
+ "error.auth.required": "Sign in to continue.",
+ "error.request.body_invalid": "The request body is invalid.",
+ "error.request.body_too_large": "The request body is too large.",
+ "error.request.page_invalid": "The requested page is invalid.",
+ "error.request.parameter_invalid": "A request parameter is invalid.",
"error.user.invalid_id": "The user ID is invalid.",
"error.invitation.invalid_id": "The invitation ID is invalid.",
"error.invitation.accept_failed": "Unable to accept the invitation.",
@@ -295,16 +461,24 @@ export const en = {
"error.channel.delete_failed": "Unable to delete the channel.",
"error.channel.state_failed": "Unable to change the channel state.",
"error.channel.status_failed": "Unable to load the channel status.",
+ "error.channel.subscription_event_invalid": "The selected analysis notification event is invalid.",
+ "error.channel.allowlist_invalid": "The channel sender allowlist is invalid.",
+ "error.channel.allowlist_required": "Configure at least one allowed sender.",
+ "error.channel.configuration_stale": "The channel configuration changed; reload and try again.",
+ "error.channel.credential_missing": "The channel credential is missing.",
+ "error.channel.credential_required": "Enter the channel credential.",
+ "error.channel.credentials_invalid": "The channel credentials are invalid.",
+ "error.channel.group_prefix_invalid": "The channel group prefix is invalid.",
+ "error.channel.identity_link_invalid": "The channel identity link is invalid or expired.",
+ "error.channel.name_required": "The channel name is required.",
+ "error.channel.not_found": "The channel was not found.",
+ "error.channel.type_invalid": "The channel type is invalid.",
+ "error.channel.value_required": "A required channel setting is missing.",
+ "error.channel.values_invalid": "The channel settings are invalid.",
"error.conversation.invalid_id": "The conversation ID is invalid.",
"error.conversation.list_failed": "Unable to load conversations.",
"error.message.list_failed": "Unable to load messages.",
"error.overview.get_failed": "Unable to load the overview.",
- "error.connection.invalid_id": "The connection ID is invalid.",
- "error.connection.create_failed": "Unable to create the connection.",
- "error.connection.list_failed": "Unable to load connections.",
- "error.connection.update_failed": "Unable to update the connection.",
- "error.connection.delete_failed": "Unable to delete the connection.",
- "error.connection.test_failed": "Unable to verify the connection.",
"error.repository.invalid_id": "The repository ID is invalid.",
"error.repository.create_failed": "Unable to create the repository.",
"error.repository.list_failed": "Unable to load repositories.",
@@ -313,6 +487,50 @@ export const en = {
"error.repository.delete_failed": "Unable to delete the repository.",
"error.repository.sync_failed": "Unable to synchronize the repository.",
"error.repository.cancel_failed": "Unable to cancel synchronization.",
+ "error.repository.preview_failed": "Unable to access the repository or resolve the ref.",
+ "error.repository.rename_failed": "Unable to rename the repository.",
+ "error.repository.target_failed": "Unable to update the repository target.",
+ "error.repository.archive_failed": "Unable to change the repository archive state.",
+ "error.repository.workspace_quota_exceeded": "This workspace has reached its repository limit.",
+ "error.repository.deletion_conflict": "The repository could not be deleted in its current state.",
+ "error.repository.deletion_unavailable": "Wait for the active repository operation to finish or cancel it before deleting.",
+ "error.repository.operation_not_cancellable": "The repository operation can no longer be cancelled.",
+ "error.repository.ref_required": "Enter a branch, tag, ref, or commit SHA.",
+ "error.repository.refresh_unavailable": "The repository cannot be refreshed in its current state.",
+ "error.repository.update_unavailable": "The repository source cannot be changed in its current state.",
+ "error.repository.remote_invalid": "The repository URL is invalid or unsupported.",
+ "error.repository.source_conflict": "This repository source is already managed by the workspace.",
+ "error.repository.state_conflict": "The repository changed while the operation was being requested. Refresh and try again.",
+ "error.analysis.invalid_id": "The analysis run ID is invalid.",
+ "error.analysis.list_failed": "Unable to load analysis history.",
+ "error.analysis.get_failed": "Unable to load the analysis run.",
+ "error.analysis.create_failed": "Unable to start the analysis.",
+ "error.analysis.cancel_failed": "Unable to cancel the analysis run.",
+ "error.analysis.retry_failed": "Unable to retry the analysis run.",
+ "error.analysis.repository_unavailable": "The repository is not available for analysis.",
+ "error.analysis.snapshot_required": "Synchronize the repository before starting analysis.",
+ "error.analysis.snapshot_unavailable": "The selected commit snapshot is no longer available.",
+ "error.analysis.profile_definition_invalid": "The analysis profile definition is invalid.",
+ "error.analysis.profile_definition_unsupported": "The analysis profile uses unsupported settings.",
+ "error.analysis.profile_timeout_invalid": "The profile timeout must be between 1 and 86400 seconds.",
+ "error.analysis.profile_name_required": "The analysis profile name is required.",
+ "error.analysis.profile_version_invalid": "The analysis profile version is invalid.",
+ "error.analysis.profile_version_stale": "The analysis profile changed; reload and try again.",
+ "error.analysis.profile_not_found": "The analysis profile was not found.",
+ "error.analysis.profile_required": "Create an analysis profile before starting analysis.",
+ "error.analysis.profile_last_active": "Keep at least one active analysis profile.",
+ "error.analysis.profile_snapshot_invalid": "The frozen analysis profile is invalid.",
+ "error.analysis.workspace_concurrency_exceeded": "This workspace has reached its concurrent analysis limit.",
+ "error.analysis.not_cancellable": "The analysis run can no longer be cancelled.",
+ "error.analysis.not_retryable": "The analysis run cannot be retried.",
+ "error.provider.connection_not_found": "The provider connection was not found.",
+ "error.provider.connection_mismatch": "The selected PAT does not match this repository host.",
+ "error.provider.connection_inactive": "The selected provider connection is not active.",
+ "error.provider.connection_unavailable": "The provider connection is unavailable.",
+ "error.provider.credential_invalid": "The personal access token is invalid.",
+ "error.provider.credential_stale": "The personal access token changed; retry with the current credential.",
+ "error.provider.token_required": "Enter a personal access token.",
+ "error.provider.type_invalid": "The provider type is invalid.",
"error.job.invalid_id": "The job ID is invalid.",
"error.job.get_failed": "Unable to load the job.",
"error.job.cancel_failed": "Unable to cancel the job.",
@@ -335,17 +553,24 @@ export const zhCN: Catalog = {
"common.provider": "服务商",
"common.platform": "平台",
"common.cancel": "取消",
+ "common.retry": "重试",
+ "common.archive": "归档",
+ "common.restore": "恢复",
"common.save": "保存",
"common.test": "测试",
"common.copy": "复制",
+ "common.copied": "已复制",
+ "common.copyFailed": "复制失败",
"common.delete": "删除",
"common.close": "关闭",
"common.enabled": "已启用",
"common.disabled": "已停用",
"common.loading": "加载中",
+ "common.never": "从未",
"common.requestFailed": "请求失败",
"common.viewAll": "查看全部",
"common.loadOlder": "加载更早消息",
+ "common.loadMore": "加载更多",
"common.signOut": "退出登录",
"common.workspace": "工作空间",
@@ -367,10 +592,10 @@ export const zhCN: Catalog = {
"landing.capability.assessment.description": "持续发现代码中的质量风险,让 AI 生成的代码同样经得起工程标准检验。",
"landing.capability.messaging.title": "融入团队沟通",
"landing.capability.messaging.description": "通过飞书、钉钉等团队熟悉的入口与 MoonCode 交互,让质量反馈自然进入协作流程。",
- "landing.capability.saas.title": "多租户 SaaS",
- "landing.capability.saas.description": "以 Workspace 隔离组织、成员和数据,提供清晰的身份认证与角色授权边界。",
+ "landing.capability.saas.title": "Workspace 协作",
+ "landing.capability.saas.description": "在清晰的 Workspace 数据边界内组织代码仓库、成员、分析任务和团队消息渠道。",
"landing.capability.source.title": "连接你的代码",
- "landing.capability.source.description": "从 GitHub、GitLab 和通用 Git 服务同步代码,让每一次评估都精确对应一个 commit。",
+ "landing.capability.source.description": "从 GitHub 和 GitLab 同步代码,让每一次评估都精确对应一个 commit。",
"landing.workflow.eyebrow": "工作方式",
"landing.workflow.title": "从一次提交,到持续改进。",
"landing.workflow.description": "让代码质量成为研发过程中的日常信号,而不是发布前才进行的一次性检查。",
@@ -383,8 +608,8 @@ export const zhCN: Catalog = {
"landing.foundation.eyebrow": "为团队而生",
"landing.foundation.title": "连接现有工具,守住每个 Workspace 的边界。",
"landing.foundation.auth": "GitHub OAuth 登录,认证流程交由 tinyauth 处理",
- "landing.foundation.authorization": "Workspace 级数据隔离与 Owner、Admin、Member、Viewer 授权",
- "landing.foundation.secrets": "Git 与 IM 凭据加密保存,敏感信息不通过 API 回显",
+ "landing.foundation.authorization": "通过 Workspace 成员边界隔离团队协作资源",
+ "landing.foundation.secrets": "个人 SCM 凭据与 Workspace 消息渠道凭据均加密保存,且不通过 API 回显",
"landing.cta.title": "让高质量代码,跟上 AI 的速度。",
"landing.cta.description": "从连接第一个代码仓库开始,为团队建立持续、清晰的代码质量反馈。",
"landing.cta.action": "进入 MoonCode",
@@ -407,7 +632,7 @@ export const zhCN: Catalog = {
"landing.preview.caption": "产品界面示意 · 评估能力将随平台演进持续完善",
"auth.accountSuspended.title": "账户已暂停",
- "auth.accountSuspended.description": "你的 MoonCode 账户当前已暂停。如有疑问,请联系工作空间管理员。",
+ "auth.accountSuspended.description": "你的 MoonCode 账户当前已暂停。如有疑问,请联系部署运维人员。",
"auth.registrationClosed.title": "注册已关闭",
"auth.registrationClosed.description": "当前 MoonCode 部署暂不接受新账户。",
"auth.unavailable.title": "MoonCode 暂不可用",
@@ -426,7 +651,7 @@ export const zhCN: Catalog = {
"auth.setup.createTitle": "创建 MoonCode 账户",
"auth.invitation.requiredTitle": "需要工作空间邀请",
"auth.invitation.signedIn": "当前以 {email} 登录。确认相关政策后即可加入工作空间。",
- "auth.setup.welcome": "欢迎,{name}。确认相关政策后即可创建个人工作空间。",
+ "auth.setup.welcome": "欢迎,{name}。确认相关政策后即可激活账户。",
"auth.invitation.requiredDescription": "身份认证已完成,但当前部署只允许通过工作空间邀请激活账户。",
"auth.acceptTerms": "我接受服务条款 {version}",
"auth.acceptPrivacy": "我接受隐私政策 {version}",
@@ -436,9 +661,12 @@ export const zhCN: Catalog = {
"nav.toggle": "切换导航",
"nav.workspace": "工作空间",
+ "nav.account": "账户设置",
+ "nav.qualityWorkflow": "质量工作流",
+ "nav.teamCollaboration": "团队协作",
"nav.overview": "概览",
"nav.repositories": "代码仓库",
- "nav.connections": "代码连接",
+ "nav.analysis": "质量分析",
"nav.channels": "消息渠道",
"nav.messages": "消息",
"nav.settings": "设置",
@@ -452,58 +680,159 @@ export const zhCN: Catalog = {
"overview.heroTitle": "为 AI 代码质量建立可靠基础",
"overview.heroDescription": "连接 Git 代码仓库以及飞书或钉钉。MoonCode 让评估精确对应 commit,并把团队带入同一套质量流程。",
"overview.repositories": "代码仓库",
+ "overview.withoutCode": "尚无同步代码",
+ "overview.failedSyncs": "近期同步失败",
+ "overview.activeAnalyses": "运行中的分析",
"overview.activeChannels": "活跃渠道",
"overview.recentMessages": "最近消息",
"overview.failedJobs": "失败任务",
"overview.recentRepositories": "最近仓库",
"overview.noMessages": "暂无消息",
"overview.noMessagesDescription": "已连接渠道收到的消息会显示在这里。",
+ "overview.attentionTitle": "待处理事项",
+ "overview.attentionDescription": "有 {count} 项需要处理。",
+ "overview.allClear": "当前质量工作流运行正常。",
+ "overview.failedSyncAction": "查看失败的仓库同步任务。",
+ "overview.unsyncedAction": "同步尚未生成 commit 快照的仓库。",
+ "overview.noAttention": "一切就绪,目前没有需要处理的仓库或工作流问题。",
+ "overview.setupTitle": "工作空间配置",
+ "overview.setupProgress": "已完成 {completed}/{total} 个必要步骤",
+ "overview.setupRepository": "添加第一个代码仓库",
+ "overview.setupSync": "同步一个 commit 快照",
+ "overview.setupChannel": "连接团队消息渠道",
+ "overview.synchronized": "已同步",
"repositories.title": "代码仓库",
"repositories.description": "每个仓库只保留用户显式同步的当前 commit。",
"repositories.add": "添加仓库",
- "repositories.cloneURL": "克隆地址",
- "repositories.refOptional": "分支、Tag 或 commit(可选)",
- "repositories.genericHTTPS": "通用 Git HTTPS",
- "repositories.connectAndSync": "连接并同步",
+ "repositories.addAndSync": "添加并同步",
"repositories.empty": "暂无代码仓库",
"repositories.emptyDescription": "连接一个代码仓库以同步当前源码。",
"repositories.cancelSync": "取消同步",
"repositories.sync": "立即同步",
"repositories.delete": "删除仓库",
"repositories.deleteConfirm": "删除 {name} 及其本地源码?",
- "repositories.syncRefPrompt": "请输入要同步的分支、Tag 或 commit;留空则使用远程默认分支。",
- "repositories.remoteDefault": "远程默认分支",
"repositories.notSynced": "尚未同步",
+ "repositories.remoteUrl": "仓库地址",
+ "repositories.rename": "重命名仓库",
+ "repositories.ref": "分支、Tag 或 Commit(ref)",
+ "repositories.nameOptional": "名称(可选)",
+ "repositories.namePlaceholder": "留空时根据仓库地址自动生成",
+ "repositories.credentialOptional": "个人 PAT(可选)",
+ "repositories.publicAccess": "不使用凭证(公开仓库)",
+ "repositories.chooseRef": "存在同名分支和 Tag,请选择要跟踪的 ref。",
+ "repositories.syncHistory": "同步历史",
+ "repositories.targetExample": "main / v1.0.0 / commit SHA",
+ "repositories.changeTarget": "修改目标",
+ "repositories.saveTarget": "保存并同步",
+ "repositories.searchPlaceholder": "搜索代码仓库",
+ "repositories.noSearchResults": "没有匹配的代码仓库。",
+ "repositories.createDescription": "输入 Git 仓库和 ref,MoonCode 将创建托管 mirror 并同步当前 commit。",
+ "repositories.analyze": "质量分析",
+ "repositories.currentCommit": "当前 commit",
+ "repositories.trackedRef": "跟踪 ref",
+ "repositories.lastSync": "最近同步",
+ "repositories.mirrorSize": "Mirror 大小",
+ "repositories.detailTabs": "仓库详情分区",
+ "repositories.activity": "活动历史",
+ "repositories.syncHistoryDescription": "仓库同步与源码目标变更记录。",
+ "repositories.noActivity": "暂无仓库活动",
+ "repositories.noActivityDescription": "同步操作将显示在这里。",
+ "repositories.generalSettings": "常规设置",
+ "repositories.generalSettingsDescription": "修改该仓库在工作空间内显示的名称。",
+ "repositories.sourceSettings": "仓库来源",
+ "repositories.sourceSettingsDescription": "修改地址或 ref 后,将同步并生成新的当前 commit 快照。",
+ "repositories.dangerZone": "危险操作",
+ "repositories.dangerZoneDescription": "归档仓库,或永久删除其托管 mirror 与历史记录。",
+ "repositories.copyCommit": "复制 commit SHA",
+ "repositories.operationProvision": "首次同步",
+ "repositories.operationRefresh": "同步 ref",
+ "repositories.operationUpdate": "更新仓库来源",
+ "repositories.operationPurge": "删除仓库数据",
+ "repositories.outcomeChanged": "发现新 commit",
+ "repositories.outcomeNoChange": "已是最新",
+
+ "providerConnections.title": "代码托管连接",
+ "providerConnections.description": "管理你私有的 GitHub 与 GitLab PAT。连接仅属于你的个人账号。",
+ "providerConnections.add": "添加代码托管连接",
+ "providerConnections.baseUrl": "基础地址",
+ "providerConnections.token": "个人访问令牌",
+ "providerConnections.privateHint": "仅限你的个人账号使用,不会共享给 Workspace",
+ "providerConnections.default": "默认连接",
+ "providerConnections.makeDefault": "设为默认",
+ "providerConnections.rotate": "更换 PAT",
+ "providerConnections.newToken": "新的个人访问令牌",
+ "providerConnections.rotateSave": "验证并更换",
+ "providerConnections.deleteConfirm": "删除代码托管连接 {name}?",
+ "providerConnections.testSucceeded": "连接验证成功。",
- "connections.title": "代码连接",
- "connections.description": "凭据会加密保存,API 永远不会返回凭据明文。",
- "connections.new": "新建连接",
- "connections.baseURL": "服务地址",
- "connections.username": "用户名",
- "connections.accessToken": "访问令牌",
- "connections.genericGit": "通用 Git",
- "connections.save": "保存连接",
- "connections.empty": "暂无代码连接",
- "connections.emptyDescription": "添加私有 Git 仓库所需的访问凭据。",
- "connections.credentialConfigured": "已配置凭据",
- "connections.publicAccess": "公开访问",
- "connections.delete": "删除连接",
- "connections.deleteConfirm": "删除 {name}?",
- "connections.displayName": "显示名称",
- "connections.replaceToken": "替换访问令牌",
- "connections.keepToken": "留空以保留当前令牌",
- "connections.repositoryURL": "仓库地址",
- "connections.succeeded": "连接成功。",
+ "analysis.run": "分析快照",
+ "analysis.snapshot": "Commit 快照",
+ "analysis.profile": "分析配置",
+ "analysis.profileOption": "{name} · v{version}",
+ "analysis.profiles": "分析配置",
+ "analysis.profilesDescription": "每次任务都会固化版本化的分析器设置。",
+ "analysis.addProfile": "添加配置",
+ "analysis.timeoutSeconds": "超时(秒)",
+ "analysis.saveProfile": "保存配置",
+ "analysis.createVersion": "创建新版本",
+ "analysis.profileMetadata": "v{version} · 超时 {timeout} 秒 · scc",
+ "analysis.title": "质量分析",
+ "analysis.description": "对当前工作空间内仓库的精确代码快照进行分析。",
+ "analysis.empty": "暂无可分析的仓库",
+ "analysis.emptyDescription": "请先添加并同步代码仓库,再运行质量分析。",
+ "analysis.noSnapshot": "请先同步该仓库,再运行分析。",
+ "analysis.codeScale": "代码规模",
+ "analysis.none": "当前快照尚未执行分析。",
+ "analysis.summary": "{files} 个文件 · {code} 行代码 · {languages} 种语言",
+ "analysis.manageProfiles": "管理配置",
+ "analysis.chooseRepository": "选择仓库",
+ "analysis.stepSnapshot": "1. Commit 快照",
+ "analysis.stepProfile": "2. 分析配置",
+ "analysis.noSnapshotTitle": "暂无 commit 快照",
+ "analysis.noProfilesTitle": "暂无分析配置",
+ "analysis.noProfilesDescription": "请先创建分析配置,再运行仓库分析。",
+ "analysis.metricFiles": "文件数",
+ "analysis.metricCode": "代码行数",
+ "analysis.metricLanguages": "语言数",
+ "analysis.metricComplexity": "复杂度",
+ "analysis.languageDistribution": "语言分布",
+ "analysis.reportMetadata": "Commit {commit} · 用时 {duration} 毫秒",
+ "analysis.languageFiles": "{count} 个文件",
+ "analysis.noReportTitle": "可以开始分析",
+ "analysis.runHistory": "运行历史",
+ "analysis.reportTitle": "分析报告",
+ "analysis.reportSelection": "Commit {commit} · 配置 {profile}",
+ "analysis.backToSelection": "返回当前选择",
+ "analysis.runStatus": "分析执行中",
+ "analysis.runStatusDescription": "阶段:{stage} · 第 {attempt} 次执行",
+ "analysis.runHistoryDescription": "打开任意历史任务,查看当时的状态或报告。",
+ "analysis.attempt": "第 {attempt} 次执行",
+ "analysis.viewReport": "查看报告",
+ "analysis.metricLines": "总行数",
+ "analysis.metricComments": "注释行数",
+ "analysis.metricBlanks": "空白行数",
+ "analysis.metricBytes": "源码大小",
+ "analysis.metricDuration": "执行耗时",
+ "analysis.metricAnalyzer": "分析器",
+ "analysis.languageShare": "{code} · {percentage}%",
+ "analysis.warnings": "分析器警告",
+ "analysis.warningsDescription": "分析已完成,但分析器报告了以下情况。",
"channels.title": "消息渠道",
"channels.description": "通过托管的长连接接收团队消息。",
"channels.add": "添加渠道",
+ "channelLink.linking": "正在绑定聊天身份…",
+ "channelLink.success": "聊天身份已绑定到当前 MoonCode 用户。",
"channels.engineeringBot": "研发机器人",
"channels.appID": "App ID",
"channels.clientID": "Client ID",
"channels.appSecret": "App Secret",
"channels.clientSecret": "Client Secret",
+ "channels.robotCode": "机器人编码",
+ "channels.chatID": "目标群聊 ID",
+ "channels.conversationID": "开放会话 ID",
+ "channels.useLark": "使用 Lark 国际版接口",
"channels.allowedSenders": "允许的发送者 ID",
"channels.allowedSendersHelp": "使用逗号分隔;只有信任所有发送者时才使用 *。",
"channels.groupPrefix": "群聊前缀",
@@ -519,6 +848,21 @@ export const zhCN: Catalog = {
"channels.deleteConfirm": "删除 {name}?",
"channels.requireMentionShort": "要求 @ 或命令前缀",
"channels.saveChanges": "保存更改",
+ "channels.replacementSecret": "输入新的密钥",
+ "channels.rotateCredential": "更换凭据",
+ "channels.notifications": "分析通知",
+ "channels.notifyStarted": "开始",
+ "channels.notifySucceeded": "成功",
+ "channels.notifyFailed": "失败",
+ "channels.createDescription": "连接飞书、Lark 或钉钉应用,用于接收消息和发送分析通知。",
+ "channels.connectionStatus": "连接状态",
+ "channels.lastConnected": "最近连接",
+ "channels.credential": "凭证",
+ "channels.configured": "已配置",
+ "channels.receivingMessages": "正在接收消息",
+ "channels.notReceivingMessages": "消息接收已暂停",
+ "channels.configureTitle": "配置 {name}",
+ "channels.configureDescription": "更新消息访问规则、分析通知和渠道凭证。",
"messages.title": "消息",
"messages.description": "查看已连接渠道接收并标准化的消息。",
@@ -529,17 +873,27 @@ export const zhCN: Catalog = {
"messages.empty": "暂无消息",
"messages.emptyDescription": "飞书和钉钉的消息会显示在这里。",
"messages.via": "来自 {channel}",
+ "messages.channels": "消息渠道",
+ "messages.conversations": "会话",
+ "messages.noChannels": "尚未配置消息渠道。",
+ "messages.noConversations": "暂无会话。",
+ "messages.allMessages": "全部消息",
+ "messages.conversationVia": "通过 {channel} 接收的会话",
+ "messages.streamDescription": "展示所选渠道与会话中的消息。",
- "settings.title": "工作空间设置",
- "settings.description": "管理工作空间,并通过已验证的 GitHub 邮箱邀请成员。",
+ "settings.title": "设置",
+ "settings.description": "管理你的个人代码托管连接以及当前工作空间。",
"settings.general": "基本信息",
"settings.workspaceName": "工作空间名称",
+ "settings.reportRetentionDays": "分析数据保留天数",
+ "settings.reportRetentionDescription": "超过该期限的终态分析任务和报告会被清理,进行中的任务不会被删除。",
"settings.invitations": "邀请",
"settings.invitationsDescription": "接收者必须使用此处填写的已验证 GitHub 邮箱登录。",
"settings.email": "邮箱",
- "settings.role": "角色",
"settings.createInvitation": "创建邀请",
"settings.copyInvitation": "请立即复制此链接,邀请令牌只会返回一次。",
+ "settings.noInvitations": "暂无邀请记录。",
+ "settings.noMembers": "暂无工作空间成员。",
"settings.accepted": "已接受",
"settings.revoked": "已撤销",
"settings.expired": "已过期",
@@ -550,11 +904,19 @@ export const zhCN: Catalog = {
"settings.createWorkspace": "创建其他工作空间",
"settings.create": "创建",
"settings.emptyModule": "{title} 已准备好承载下一阶段产品能力。",
+ "settings.tabs": "设置分区",
+ "settings.workspaceTab": "工作空间",
+ "settings.teamTab": "团队成员与邀请",
+ "settings.credentialsTab": "个人代码凭证",
+ "settings.createWorkspaceDescription": "创建一个仓库、成员和渠道完全独立的新工作空间。",
+ "settings.workspaceManageDescription": "管理工作空间信息和分析报告保留周期。",
+ "settings.workspaceReadOnlyDescription": "只有工作空间所有者和管理员可以修改这些设置。",
+ "settings.adminOnlyDescription": "只有工作空间所有者和管理员可以创建或撤销邀请。",
+ "settings.roleOwner": "所有者",
+ "settings.roleAdmin": "管理员",
+ "settings.roleMember": "成员",
+ "settings.removeMemberConfirm": "将 {name} 移出当前工作空间?",
- "role.owner": "所有者",
- "role.admin": "管理员",
- "role.member": "成员",
- "role.viewer": "访客",
"state.pending": "等待中",
"state.queued": "排队中",
"state.active": "活跃",
@@ -566,6 +928,7 @@ export const zhCN: Catalog = {
"state.running": "运行中",
"state.connected": "已连接",
"state.disabled": "已停用",
+ "state.error": "错误",
"state.uploading": "上传中",
"state.deleting": "删除中",
"state.cancelled": "已取消",
@@ -573,6 +936,9 @@ export const zhCN: Catalog = {
"state.retry_wait": "等待重试",
"state.suspended": "已暂停",
"state.deleted": "已删除",
+ "state.owner": "所有者",
+ "state.admin": "管理员",
+ "state.member": "成员",
"error.http.request_failed": "请求失败。",
"error.request.invalid_json": "请求内容格式不正确。",
@@ -605,8 +971,26 @@ export const zhCN: Catalog = {
"error.workspace.list_failed": "无法加载工作空间列表。",
"error.workspace.create_failed": "无法创建工作空间。",
"error.workspace.update_failed": "无法更新工作空间。",
+ "error.workspace.update_required": "请至少修改一项工作空间设置。",
+ "error.workspace.name_required": "必须填写工作空间名称。",
+ "error.workspace.report_retention_invalid": "分析数据保留天数必须在 1 到 3650 之间。",
"error.workspace.members_failed": "无法加载工作空间成员。",
"error.workspace.member_failed": "无法更新工作空间成员。",
+ "error.workspace.last_member": "工作空间必须至少保留一名成员。",
+ "error.workspace.forbidden": "你没有访问此工作空间的权限。",
+ "error.workspace.invitation_email_invalid": "请输入有效的邀请邮箱。",
+ "error.workspace.invitation_not_found": "未找到工作空间邀请。",
+ "error.workspace.invitation_recipient_mismatch": "此邀请属于其他账户。",
+ "error.workspace.invitation_unavailable": "此工作空间邀请已不可用。",
+ "error.workspace.member_not_removable": "无法移除此工作空间成员。",
+ "error.account.registration_closed": "当前未开放注册。",
+ "error.account.terms_required": "请接受服务条款和隐私政策后继续。",
+ "error.auth.identity_failed": "无法验证登录身份。",
+ "error.auth.required": "请登录后继续。",
+ "error.request.body_invalid": "请求内容无效。",
+ "error.request.body_too_large": "请求内容过大。",
+ "error.request.page_invalid": "请求的分页参数无效。",
+ "error.request.parameter_invalid": "请求参数无效。",
"error.user.invalid_id": "用户 ID 无效。",
"error.invitation.invalid_id": "邀请 ID 无效。",
"error.invitation.accept_failed": "无法接受邀请。",
@@ -619,17 +1003,25 @@ export const zhCN: Catalog = {
"error.channel.update_failed": "无法更新渠道。",
"error.channel.delete_failed": "无法删除渠道。",
"error.channel.state_failed": "无法更改渠道状态。",
+ "error.channel.subscription_event_invalid": "选择的分析通知事件无效。",
+ "error.channel.allowlist_invalid": "消息渠道发送者白名单无效。",
+ "error.channel.allowlist_required": "请至少配置一名允许的发送者。",
+ "error.channel.configuration_stale": "消息渠道配置已变更,请刷新后重试。",
+ "error.channel.credential_missing": "消息渠道凭证缺失。",
+ "error.channel.credential_required": "请输入消息渠道凭证。",
+ "error.channel.credentials_invalid": "消息渠道凭证无效。",
+ "error.channel.group_prefix_invalid": "群聊命令前缀无效。",
+ "error.channel.identity_link_invalid": "消息身份绑定链接无效或已过期。",
+ "error.channel.name_required": "必须填写消息渠道名称。",
+ "error.channel.not_found": "未找到消息渠道。",
+ "error.channel.type_invalid": "消息渠道类型无效。",
+ "error.channel.value_required": "缺少必填的消息渠道设置。",
+ "error.channel.values_invalid": "消息渠道设置无效。",
"error.channel.status_failed": "无法加载渠道状态。",
"error.conversation.invalid_id": "会话 ID 无效。",
"error.conversation.list_failed": "无法加载会话列表。",
"error.message.list_failed": "无法加载消息。",
"error.overview.get_failed": "无法加载概览。",
- "error.connection.invalid_id": "连接 ID 无效。",
- "error.connection.create_failed": "无法创建连接。",
- "error.connection.list_failed": "无法加载连接列表。",
- "error.connection.update_failed": "无法更新连接。",
- "error.connection.delete_failed": "无法删除连接。",
- "error.connection.test_failed": "无法验证连接。",
"error.repository.invalid_id": "代码仓库 ID 无效。",
"error.repository.create_failed": "无法创建代码仓库。",
"error.repository.list_failed": "无法加载代码仓库列表。",
@@ -638,6 +1030,50 @@ export const zhCN: Catalog = {
"error.repository.delete_failed": "无法删除代码仓库。",
"error.repository.sync_failed": "无法同步代码仓库。",
"error.repository.cancel_failed": "无法取消同步。",
+ "error.repository.preview_failed": "无法访问代码仓库或识别 ref。",
+ "error.repository.rename_failed": "无法重命名代码仓库。",
+ "error.repository.target_failed": "无法修改代码仓库目标。",
+ "error.repository.archive_failed": "无法修改代码仓库归档状态。",
+ "error.repository.workspace_quota_exceeded": "工作空间已达到代码仓库数量上限。",
+ "error.repository.deletion_conflict": "当前状态下无法删除代码仓库。",
+ "error.repository.deletion_unavailable": "请等待当前代码仓库操作完成,或先取消操作再删除。",
+ "error.repository.operation_not_cancellable": "代码仓库操作已无法取消。",
+ "error.repository.ref_required": "请输入分支、标签、ref 或 commit SHA。",
+ "error.repository.refresh_unavailable": "当前状态下无法刷新代码仓库。",
+ "error.repository.update_unavailable": "当前状态下无法修改代码仓库来源。",
+ "error.repository.remote_invalid": "代码仓库地址无效或不受支持。",
+ "error.repository.source_conflict": "该代码仓库来源已由当前工作空间管理。",
+ "error.repository.state_conflict": "请求操作期间代码仓库状态已变化,请刷新后重试。",
+ "error.analysis.invalid_id": "分析运行 ID 无效。",
+ "error.analysis.list_failed": "无法加载分析历史。",
+ "error.analysis.get_failed": "无法加载分析运行。",
+ "error.analysis.create_failed": "无法开始分析。",
+ "error.analysis.cancel_failed": "无法取消分析运行。",
+ "error.analysis.retry_failed": "无法重试分析运行。",
+ "error.analysis.repository_unavailable": "当前代码仓库无法执行分析。",
+ "error.analysis.snapshot_required": "请先同步代码仓库再开始分析。",
+ "error.analysis.snapshot_unavailable": "选择的 commit 快照已不可用。",
+ "error.analysis.profile_definition_invalid": "分析配置定义无效。",
+ "error.analysis.profile_definition_unsupported": "分析配置包含不支持的设置。",
+ "error.analysis.profile_timeout_invalid": "配置超时必须在 1 到 86400 秒之间。",
+ "error.analysis.profile_name_required": "必须填写分析配置名称。",
+ "error.analysis.profile_version_invalid": "分析配置版本无效。",
+ "error.analysis.profile_version_stale": "分析配置已变更,请刷新后重试。",
+ "error.analysis.profile_not_found": "未找到分析配置。",
+ "error.analysis.profile_required": "请先创建分析配置再开始分析。",
+ "error.analysis.profile_last_active": "至少保留一个有效的分析配置。",
+ "error.analysis.profile_snapshot_invalid": "任务固化的分析配置无效。",
+ "error.analysis.workspace_concurrency_exceeded": "工作空间已达到并发分析上限。",
+ "error.analysis.not_cancellable": "分析运行已无法取消。",
+ "error.analysis.not_retryable": "分析运行无法重试。",
+ "error.provider.connection_not_found": "未找到代码托管连接。",
+ "error.provider.connection_mismatch": "所选个人 PAT 与该仓库地址不匹配。",
+ "error.provider.connection_inactive": "所选代码托管连接当前未启用。",
+ "error.provider.connection_unavailable": "代码托管连接当前不可用。",
+ "error.provider.credential_invalid": "个人访问令牌无效。",
+ "error.provider.credential_stale": "个人访问令牌已变更,请使用当前凭证重试。",
+ "error.provider.token_required": "请输入个人访问令牌。",
+ "error.provider.type_invalid": "代码托管平台类型无效。",
"error.job.invalid_id": "任务 ID 无效。",
"error.job.get_failed": "无法加载任务。",
"error.job.cancel_failed": "无法取消任务。",
diff --git a/frontend/src/i18n/context.ts b/frontend/src/i18n/context.ts
index fc07018..c979608 100644
--- a/frontend/src/i18n/context.ts
+++ b/frontend/src/i18n/context.ts
@@ -10,7 +10,6 @@ export type I18nContextValue = {
t: (key: TranslationKey, params?: TranslationParams) => string
formatDateTime: (value: string | number | Date) => string
formatState: (state: string) => string
- formatRole: (role: string) => string
formatError: (error: unknown) => string
}
diff --git a/frontend/src/i18n/format.ts b/frontend/src/i18n/format.ts
index 0729658..d7d1362 100644
--- a/frontend/src/i18n/format.ts
+++ b/frontend/src/i18n/format.ts
@@ -21,10 +21,6 @@ export function formatState(catalog: Catalog, state: string): string {
return catalogValue(catalog, `state.${state}`, state)
}
-export function formatRole(catalog: Catalog, role: string): string {
- return catalogValue(catalog, `role.${role}`, role)
-}
-
export function formatError(catalog: Catalog, error: unknown): string {
const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : ""
return catalogValue(catalog, `error.${code}`, catalog["error.fallback"])
diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts
index 3c54712..468daad 100644
--- a/frontend/src/lib/api/client.ts
+++ b/frontend/src/lib/api/client.ts
@@ -8,24 +8,19 @@ export class APIError extends Error {
readonly status: number
readonly code: string
readonly requestId: string
- readonly loginURL: string
- constructor(status: number, body: ErrorBody | null, loginURL = "") {
+ constructor(status: number, body: ErrorBody | null) {
super(body?.error?.message ?? `Request failed (${status})`)
this.name = "APIError"
this.status = status
this.code = body?.error?.code ?? "http.request_failed"
this.requestId = body?.error?.requestId ?? ""
- this.loginURL = loginURL
}
}
async function responseError(response: Response): Promise {
const body = await response.clone().json().catch(() => null) as ErrorBody | null
- const location = response.status === 401 ? response.headers.get("X-Tinyauth-Location") ?? "" : ""
- const loginURL = location ? new URL(location, window.location.origin) : null
- if (loginURL) loginURL.searchParams.set("redirect_uri", window.location.href)
- return new APIError(response.status, body, loginURL?.toString() ?? "")
+ return new APIError(response.status, body)
}
async function issueCSRFToken(): Promise {
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
index 47a3567..bc01ef7 100644
--- a/frontend/src/lib/api/types.ts
+++ b/frontend/src/lib/api/types.ts
@@ -1,33 +1,23 @@
-export type Workspace = { id: string; name: string; role: string; createdBy: string }
-export type User = {
- id: string
- displayName: string
- username: string
- email: string
- status: "pending" | "active" | "suspended" | "deleted"
-}
-export type Session = {
- user: User
- workspaces: Workspace[]
- auth: { logoutUrl: string }
- registration: { mode: "disabled" | "invite_only" | "public"; termsVersion: string; privacyVersion: string }
-}
-export type WorkspaceInvitation = {
- id: string
- workspaceId: string
- email: string
- role: "admin" | "member" | "viewer"
- token?: string
- expiresAt: string
- acceptedAt?: string
- revokedAt?: string
- createdAt: string
-}
-export type Repository = { id: string; name: string; cloneUrl: string; ref: string; currentCommitSha?: string; state: string; lastErrorMessage?: string; syncedAt?: string; updatedAt: string }
-export type Connection = { id: string; name: string; type: string; baseUrl: string; authType: string; secretConfigured: boolean }
-export type ChannelInstance = { id: string; type: string; name: string; enabled: boolean; secretConfigured: boolean; configVersion: number; config: { values: Record; senderAllowList: string[]; groupPolicy: { requireMention: boolean; prefix: string } } }
+export type Workspace = { id: string; name: string; slug: string; reportRetentionDays: number; createdBy: string; role: "member" | "admin" | "owner" }
+export type User = { id: string; displayName: string; username: string; email: string; status: "pending" | "active" | "suspended" | "deleted" }
+export type Session = { user: User; workspaces: Workspace[]; auth: { logoutUrl: string }; registration: { mode: "disabled" | "invite_only" | "public"; termsVersion: string; privacyVersion: string } }
+export type WorkspaceInvitation = { id: string; workspaceId: string; email: string; token?: string; expiresAt: string; acceptedAt?: string; revokedAt?: string; createdAt: string }
+export type WorkspaceMember = { userId: string; username: string; email: string; displayName: string; role: "member" | "admin" | "owner"; createdAt: string }
+export type PageResponse = { items: T[]; nextCursor?: string }
+
+export type ProviderConnection = { id: string; providerType: "github" | "gitlab"; baseUrl: string; providerAccountId?: string; login?: string; displayName?: string; scopes: string[]; isDefault: boolean; status: "active" | "invalid" | "revoked"; credentialVersion: number; lastValidatedAt?: string; lastUsedAt?: string; lastErrorCode?: string; createdAt: string; updatedAt: string }
+export type RepositorySnapshot = { id: string; repositoryId: string; commitSha: string; sourceRef: string; authorName?: string; authoredAt?: string; title?: string; sourceState: "available" | "purging" | "purged"; createdAt: string }
+export type RepositoryOperation = { id: string; repositoryId: string; actorUserId: string; providerConnectionId?: string; credentialVersion?: number; repositoryVersion: number; kind: "provision" | "refresh" | "update" | "purge"; requestedProviderType: "github" | "gitlab"; requestedRemoteUrl: string; requestedNormalizedUrl: string; requestedRef: string; status: string; outcome?: "changed" | "no_change"; resolvedCommitSha?: string; snapshotId?: string; errorMessage?: string; createdAt: string; startedAt?: string; finishedAt?: string }
+export type Repository = { id: string; workspaceId: string; providerType: "github" | "gitlab"; name: string; remoteUrl: string; normalizedUrl: string; ref: string; configVersion: number; status: "provisioning" | "syncing" | "ready" | "failed" | "deleting" | "deleted"; currentSnapshotId?: string; currentSnapshot?: RepositorySnapshot; mirrorSizeBytes: number; lastSyncAt?: string; lastErrorCode?: string; lastErrorMessage?: string; archivedAt?: string; deletedAt?: string; createdAt: string; updatedAt: string }
+export type CodeScaleResult = { summary: { files: number; lines: number; code: number; comments: number; blanks: number; bytes: number; complexity: number }; languages: Array<{ name: string; files: number; lines: number; code: number; comments: number; blanks: number; bytes: number; complexity: number }>; warnings: string[] }
+export type AnalysisProfileDefinition = { dimensionKey: "code_scale"; timeoutSeconds: number; analyzer: { key: "scc"; required: true } }
+export type AnalysisProfile = { id: string; workspaceId: string; name: string; currentVersion: number; dimensionKey: "code_scale"; definition: AnalysisProfileDefinition; createdBy: string; createdAt: string; updatedAt: string; versionCreatedAt: string }
+export type AnalysisReport = { id: string; analysisRunId: string; repositoryId: string; snapshotId: string; commitSha: string; sourceRef: string; commitAuthor?: string; commitAuthoredAt?: string; commitTitle?: string; dimensionKey: string; profileId: string; profileVersion: string; profileSnapshot: AnalysisProfileDefinition; analyzerVersion: string; executionEnvironment: string; startedAt: string; finishedAt: string; durationMs: number; result: CodeScaleResult; createdAt: string }
+export type AnalysisRun = { id: string; repositoryId: string; snapshotId: string; commitSha: string; dimensionKey: string; profileId: string; profileVersion: string; profileSnapshot: AnalysisProfileDefinition; analyzerVersion: string; attempt: number; rerunOf?: string; status: string; stage: "queued" | "prepare" | "authorize" | "checkout" | "analyze" | "persist" | "workflow" | "complete" | "cancelled"; reportId?: string; report?: AnalysisReport; workflowRunId?: string; failedStage?: "prepare" | "authorize" | "checkout" | "analyze" | "persist" | "workflow"; errorCode?: string; errorMessage?: string; retryable: boolean; createdAt: string; startedAt?: string; finishedAt?: string }
+
+export type ChannelNotificationEvent = "analysis.started" | "analysis.succeeded" | "analysis.failed"
+export type ChannelInstance = { id: string; type: string; name: string; enabled: boolean; secretConfigured: boolean; configVersion: number; config: { values: Record; senderAllowList: string[]; groupPolicy: { requireMention: boolean; prefix: string } }; notificationEvents: ChannelNotificationEvent[] }
export type ChannelStatus = { state: string; lastConnectedAt?: string; lastErrorMessage?: string }
export type IMMessage = { id: string; channelInstanceId: string; channelName: string; channelType: string; conversationId: string; conversationExternalId: string; senderCanonicalId: string; senderDisplayName: string; conversationType: string; content: { content: { type: string; text: string } }; occurredAt: string }
export type IMConversation = { id: string; channelInstanceId: string; channelName: string; channelType: string; externalId: string; type: string; title: string }
-export type WorkspaceOverview = { repositoryCount: number; activeChannelCount: number; failedJobCount: number; recentMessages: IMMessage[] }
-export type WorkspaceMember = { userId: string; role: string; username: string; email: string; displayName: string }
+export type WorkspaceOverview = { repositoryCount: number; repositoryWithoutCodeCount: number; recentFailedSyncCount: number; activeAnalysisCount: number; activeChannelCount: number; recentMessages: IMMessage[] }
diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts
new file mode 100644
index 0000000..ef1fa8e
--- /dev/null
+++ b/frontend/src/lib/auth.ts
@@ -0,0 +1,55 @@
+type OAuthURLResponse = {
+ url?: unknown
+}
+
+export async function oauthRedirectURL(): Promise {
+ const endpointURL = sameOriginURL("/api/oauth/url/github")
+ endpointURL.searchParams.set("redirect_uri", window.location.href)
+ const response = await fetch(endpointURL, {
+ credentials: "include",
+ headers: { Accept: "application/json" },
+ })
+ if (!response.ok) throw new Error(`OAuth initialization failed (${response.status})`)
+
+ const payload = await response.json() as OAuthURLResponse
+ if (typeof payload.url !== "string") throw new Error("OAuth initialization returned an invalid response")
+
+ const redirectURL = new URL(payload.url)
+ if (redirectURL.origin !== "https://github.com" || redirectURL.pathname !== "/login/oauth/authorize") {
+ throw new Error("OAuth initialization returned an unsafe redirect")
+ }
+
+ return redirectURL.toString()
+}
+
+export async function logout(endpoint: string): Promise {
+ const response = await fetch(sameOriginURL(endpoint), {
+ method: "POST",
+ credentials: "include",
+ headers: { Accept: "application/json" },
+ })
+ if (!response.ok) throw new Error(`Logout failed (${response.status})`)
+}
+
+export function postLoginRedirectURL(value: string | null): string {
+ const fallbackURL = new URL("/app", window.location.origin)
+ if (!value) return fallbackURL.toString()
+
+ try {
+ const redirectURL = new URL(value)
+ const isWorkbench = redirectURL.origin === window.location.origin && redirectURL.pathname === "/app"
+ const hasCredentials = Boolean(redirectURL.username || redirectURL.password)
+ if (!isWorkbench || hasCredentials) return fallbackURL.toString()
+
+ return redirectURL.toString()
+ } catch {
+ return fallbackURL.toString()
+ }
+}
+
+function sameOriginURL(value: string): URL {
+ const url = new URL(value, window.location.origin)
+ if (url.origin !== window.location.origin) throw new Error("Authentication endpoint must use the application origin")
+
+ return url
+}
diff --git a/go.mod b/go.mod
index 9d54258..8af2566 100644
--- a/go.mod
+++ b/go.mod
@@ -1,141 +1,107 @@
-module github.com/mooncode-ai/mooncode
+module github.com/fuchencong/mooncode
-go 1.25.0
+go 1.26.5
require (
- github.com/exaring/otelpgx v0.11.1
- github.com/gin-gonic/gin v1.12.0
- github.com/golang-migrate/migrate/v4 v4.19.1
+ github.com/getkin/kin-openapi v0.144.0
+ github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0
- github.com/gorilla/csrf v1.7.3
+ github.com/hatchet-dev/hatchet v0.98.9
github.com/jackc/pgx/v5 v5.9.2
- github.com/larksuite/oapi-sdk-go/v3 v3.9.9
- github.com/minio/minio-go/v7 v7.0.95
+ github.com/larksuite/oapi-sdk-go/v3 v3.9.4
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
- github.com/prometheus/client_golang v1.23.2
- github.com/rs/zerolog v1.35.1
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
- github.com/stretchr/testify v1.11.1
- github.com/testcontainers/testcontainers-go v0.43.0
- go.opentelemetry.io/otel v1.43.0
- go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
- go.opentelemetry.io/otel/sdk v1.43.0
- go.opentelemetry.io/otel/trace v1.43.0
- go.uber.org/dig v1.19.0
- golang.org/x/sync v0.20.0
)
require (
- dario.cat/mergo v1.0.2 // indirect
- github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
- github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/beorn7/perks v1.0.1 // indirect
- github.com/bytedance/gopkg v0.1.3 // indirect
- github.com/bytedance/sonic v1.15.0 // indirect
- github.com/bytedance/sonic/loader v0.5.0 // indirect
- github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ cel.dev/expr v0.25.1 // indirect
+ github.com/Masterminds/semver/v3 v3.4.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
+ github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
- github.com/cloudwego/base64x v0.1.6 // indirect
- github.com/containerd/errdefs v1.0.0 // indirect
- github.com/containerd/errdefs/pkg v0.3.0 // indirect
- github.com/containerd/log v0.1.0 // indirect
- github.com/containerd/platforms v0.2.1 // indirect
- github.com/cpuguy83/dockercfg v0.3.2 // indirect
- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
- github.com/distribution/reference v0.6.0 // indirect
- github.com/docker/go-connections v0.6.0 // indirect
- github.com/docker/go-units v0.5.0 // indirect
- github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/ebitengine/purego v0.10.0 // indirect
- github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/cockroachdb/errors v1.12.0 // indirect
+ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
+ github.com/cockroachdb/redact v1.1.5 // indirect
+ github.com/creasty/defaults v1.8.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.12 // indirect
- github.com/gin-contrib/sse v1.1.0 // indirect
- github.com/go-ini/ini v1.67.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.13 // indirect
+ github.com/getsentry/sentry-go v0.45.1 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
- github.com/go-ole/go-ole v1.2.6 // indirect
+ github.com/go-openapi/jsonpointer v0.22.5 // indirect
+ github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.30.1 // indirect
+ github.com/go-playground/validator/v10 v10.30.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
- github.com/gorilla/securecookie v1.1.2 // indirect
- github.com/gorilla/websocket v1.5.0 // indirect
+ github.com/google/cel-go v0.29.0 // indirect
+ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
+ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.18.5 // indirect
- github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
+ github.com/kr/text v0.2.0 // indirect
+ github.com/labstack/echo/v4 v4.15.1 // indirect
+ github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
- github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/minio/crc64nvme v1.0.2 // indirect
- github.com/minio/md5-simd v1.1.2 // indirect
- github.com/moby/docker-image-spec v1.3.1 // indirect
- github.com/moby/go-archive v0.2.0 // indirect
- github.com/moby/moby/api v1.54.2 // indirect
- github.com/moby/moby/client v0.4.0 // indirect
- github.com/moby/patternmatcher v0.6.1 // indirect
- github.com/moby/sys/sequential v0.6.0 // indirect
- github.com/moby/sys/user v0.4.0 // indirect
- github.com/moby/sys/userns v0.1.0 // indirect
- github.com/moby/term v0.5.2 // indirect
+ github.com/mattn/go-isatty v0.0.21 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
- github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
- github.com/opencontainers/go-digest v1.0.0 // indirect
- github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
+ github.com/oapi-codegen/runtime v1.4.0 // indirect
+ github.com/oasdiff/yaml v0.1.1 // indirect
+ github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
- github.com/philhofer/fwd v1.2.0 // indirect
- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
- github.com/prometheus/client_model v0.6.2 // indirect
- github.com/prometheus/common v0.66.1 // indirect
- github.com/prometheus/procfs v0.16.1 // indirect
- github.com/quic-go/qpack v0.6.0 // indirect
- github.com/quic-go/quic-go v0.59.0 // indirect
- github.com/rs/xid v1.6.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/rs/zerolog v1.35.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
- github.com/shirou/gopsutil/v4 v4.26.5 // indirect
- github.com/sirupsen/logrus v1.9.4 // indirect
+ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
- github.com/tinylib/msgp v1.3.0 // indirect
- github.com/tklauser/go-sysconf v0.3.16 // indirect
- github.com/tklauser/numcpus v0.11.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
- github.com/ugorji/go/codec v1.3.1 // indirect
- github.com/yusufpapurcu/wmi v1.2.4 // indirect
- go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ github.com/valyala/bytebufferpool v1.0.0 // indirect
+ github.com/valyala/fasttemplate v1.2.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
- go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
+ go.opentelemetry.io/otel/sdk v1.43.0 // indirect
+ go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
- go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/arch v0.22.0 // indirect
- golang.org/x/crypto v0.51.0 // indirect
- golang.org/x/net v0.53.0 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/crypto v0.52.0 // indirect
+ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.37.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
- google.golang.org/grpc v1.80.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
+ google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 0e69dc3..33603ee 100644
--- a/go.sum
+++ b/go.sum
@@ -1,233 +1,186 @@
-dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
-dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
-github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
-github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
-github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
-github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
-github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
-github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
-github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
-github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
-github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
-github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
-github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
-github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
+cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
-github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
-github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
-github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
-github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
-github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
-github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
-github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
-github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
-github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
-github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo=
+github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g=
+github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE=
+github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs=
+github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30=
+github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
-github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
+github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
-github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
-github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
-github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
-github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
-github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
-github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
-github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
-github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
-github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
-github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
-github.com/exaring/otelpgx v0.11.1 h1:pE79fIg/qh/Lpu00kvswFC5dKfqyJJhMJ4Y4N3w5Lj4=
-github.com/exaring/otelpgx v0.11.1/go.mod h1:3OojrUKhhy3lTbYIMBijP3YjMey/jo14eHAW5cXcUdk=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
+github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
-github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
-github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
-github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
-github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
-github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
-github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
-github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
-github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
+github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
+github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
+github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
+github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw=
+github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
+github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
-github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
+github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
+github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
+github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
-github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
+github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
-github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
-github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4=
+github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
-github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
-github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
-github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
-github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
-github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
+github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hatchet-dev/hatchet v0.98.9 h1:IMGji0rBwufWWsKdC3hnveaSTWYlii958M9coGJF18w=
+github.com/hatchet-dev/hatchet v0.98.9/go.mod h1:p7lWN5o+bZiDIUNOkA/klt4q//IRTv64GrPNvNWL4c8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
-github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
+github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
+github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/pgxlisten v0.0.0-20241106001234-1d6f6656415c h1:bTgmg761ac9Ki27HoLx8IBvc+T+Qj6eptBpKahKIRT4=
+github.com/jackc/pgxlisten v0.0.0-20241106001234-1d6f6656415c/go.mod h1:N4E1APLOYrbM11HH5kdqAjDa8RJWVwD3JqWpvH22h64=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
-github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
-github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
-github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
-github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
-github.com/larksuite/oapi-sdk-go/v3 v3.9.9 h1:qzVK5U1AuT/n0Z4LqCN2ATU6MlYp2Iipa7q+fVK5Nh8=
-github.com/larksuite/oapi-sdk-go/v3 v3.9.9/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
+github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
+github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
+github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
+github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
+github.com/larksuite/oapi-sdk-go/v3 v3.9.4 h1:oMgcY7NBjJv1QXJqFAfcoN/TbScCkCuRZfbb1mCwZmI=
+github.com/larksuite/oapi-sdk-go/v3 v3.9.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
-github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
-github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
-github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
-github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
-github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
-github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
-github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
-github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
-github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
-github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
-github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
-github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
-github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
-github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
-github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
-github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
-github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
-github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
-github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
-github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
-github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
-github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
-github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
-github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
-github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
+github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
-github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
-github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
-github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
+github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/oapi-codegen/runtime v1.4.0 h1:KLOSFOp7UzkbS7Cs1ms6NBEKYr0WmH2wZG0KKbd2er4=
+github.com/oapi-codegen/runtime v1.4.0/go.mod h1:5sw5fxCDmnOzKNYmkVNF8d34kyUeejJEY8HNT2WaPec=
+github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
+github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
+github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
+github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
-github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
-github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
-github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
+github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
+github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
-github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
-github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
-github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
-github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
-github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
-github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
-github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
-github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
-github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
-github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
-github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
-github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
-github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
-github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM=
-github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
-github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
-github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -241,50 +194,39 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
-github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A=
-github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo=
-github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
-github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
-github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
-github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
-github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
-github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
-github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
-github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
+github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
-github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
-github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
-go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
+go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
-go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
@@ -295,51 +237,43 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
-go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
-go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
-go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
-go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
-go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
-go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
-golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
-golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
+golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
+golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
-golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
-golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -350,12 +284,12 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
-google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
-google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
-google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
+google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
+google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
+google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -364,7 +298,5 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
-gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
-pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
-pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/internal/analysis/biz/failure.go b/internal/analysis/biz/failure.go
new file mode 100644
index 0000000..82b5e04
--- /dev/null
+++ b/internal/analysis/biz/failure.go
@@ -0,0 +1,8 @@
+package biz
+
+type Failure struct {
+ Stage string
+ Code string
+ Message string
+ Retryable bool
+}
diff --git a/internal/analysis/biz/idempotency.go b/internal/analysis/biz/idempotency.go
new file mode 100644
index 0000000..f5cf1ea
--- /dev/null
+++ b/internal/analysis/biz/idempotency.go
@@ -0,0 +1,25 @@
+package biz
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+func idempotencyKey(workspaceID, repositoryID, snapshotID, profileID uuid.UUID, dimensionKey, profileVersion, analyzerVersion string, profileSnapshot []byte) string {
+ profileDigest := sha256.Sum256(profileSnapshot)
+ digest := sha256.Sum256([]byte(strings.Join([]string{
+ workspaceID.String(),
+ repositoryID.String(),
+ snapshotID.String(),
+ profileID.String(),
+ dimensionKey,
+ profileVersion,
+ analyzerVersion,
+ hex.EncodeToString(profileDigest[:]),
+ }, "\x00")))
+
+ return hex.EncodeToString(digest[:])
+}
diff --git a/internal/analysis/biz/idempotency_test.go b/internal/analysis/biz/idempotency_test.go
new file mode 100644
index 0000000..2f7ad3d
--- /dev/null
+++ b/internal/analysis/biz/idempotency_test.go
@@ -0,0 +1,34 @@
+package biz
+
+import (
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestIdempotencyKeyIsStableForSameAnalysisTarget(t *testing.T) {
+ workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New()
+ profileID := uuid.New()
+ profileSnapshot := []byte(`{"dimensionKey":"code_scale"}`)
+
+ first := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.4.0", profileSnapshot)
+ second := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.4.0", profileSnapshot)
+ if first != second || len(first) != 64 {
+ t.Fatalf("unstable idempotency key: %q %q", first, second)
+ }
+ if first == idempotencyKey(workspaceID, repositoryID, uuid.New(), profileID, "code_scale", "v1", "scc-3.4.0", profileSnapshot) {
+ t.Fatal("different snapshots produced the same idempotency key")
+ }
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v2", "scc-3.4.0", profileSnapshot) {
+ t.Fatal("different profile versions produced the same idempotency key")
+ }
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, uuid.New(), "code_scale", "v1", "scc-3.4.0", profileSnapshot) {
+ t.Fatal("different profiles produced the same idempotency key")
+ }
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.4.0", []byte(`{"dimensionKey":"other"}`)) {
+ t.Fatal("different profile snapshots produced the same idempotency key")
+ }
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.5.0", profileSnapshot) {
+ t.Fatal("different analyzer versions produced the same idempotency key")
+ }
+}
diff --git a/internal/analysis/biz/model.go b/internal/analysis/biz/model.go
new file mode 100644
index 0000000..703fe29
--- /dev/null
+++ b/internal/analysis/biz/model.go
@@ -0,0 +1,71 @@
+package biz
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Run struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspaceId"`
+ RepositoryID uuid.UUID `json:"repositoryId"`
+ SnapshotID uuid.UUID `json:"snapshotId"`
+ CommitSHA string `json:"commitSha"`
+ RequestedBy uuid.UUID `json:"requestedBy"`
+ DimensionKey string `json:"dimensionKey"`
+ ProfileID uuid.UUID `json:"profileId"`
+ ProfileVersion string `json:"profileVersion"`
+ ProfileSnapshot json.RawMessage `json:"profileSnapshot"`
+ AnalyzerVersion string `json:"analyzerVersion"`
+ IdempotencyKey string `json:"-"`
+ Attempt int32 `json:"attempt"`
+ RerunOf *uuid.UUID `json:"rerunOf,omitempty"`
+ Status string `json:"status"`
+ Stage string `json:"stage"`
+ ReportID *uuid.UUID `json:"reportId,omitempty"`
+ Report *Report `json:"report,omitempty"`
+ WorkflowRunID string `json:"workflowRunId,omitempty"`
+ FailedStage string `json:"failedStage,omitempty"`
+ ErrorCode string `json:"errorCode,omitempty"`
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ Retryable bool `json:"retryable"`
+ CreatedAt time.Time `json:"createdAt"`
+ StartedAt *time.Time `json:"startedAt,omitempty"`
+ FinishedAt *time.Time `json:"finishedAt,omitempty"`
+}
+
+type Report struct {
+ ID uuid.UUID `json:"id"`
+ AnalysisRunID uuid.UUID `json:"analysisRunId"`
+ WorkspaceID uuid.UUID `json:"workspaceId"`
+ RepositoryID uuid.UUID `json:"repositoryId"`
+ SnapshotID uuid.UUID `json:"snapshotId"`
+ CommitSHA string `json:"commitSha"`
+ SourceRef string `json:"sourceRef"`
+ CommitAuthor string `json:"commitAuthor,omitempty"`
+ CommitAuthoredAt *time.Time `json:"commitAuthoredAt,omitempty"`
+ CommitTitle string `json:"commitTitle,omitempty"`
+ DimensionKey string `json:"dimensionKey"`
+ ProfileID uuid.UUID `json:"profileId"`
+ ProfileVersion string `json:"profileVersion"`
+ ProfileSnapshot json.RawMessage `json:"profileSnapshot"`
+ AnalyzerVersion string `json:"analyzerVersion"`
+ ExecutionEnvironment string `json:"executionEnvironment"`
+ StartedAt time.Time `json:"startedAt"`
+ FinishedAt time.Time `json:"finishedAt"`
+ DurationMS int64 `json:"durationMs"`
+ Result json.RawMessage `json:"result"`
+ RawArtifact json.RawMessage `json:"-"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type Execution struct {
+ Environment string
+}
+
+type WorkItem struct {
+ Run Run
+ CommitSHA string
+}
diff --git a/internal/analysis/biz/profile.go b/internal/analysis/biz/profile.go
new file mode 100644
index 0000000..2a915cc
--- /dev/null
+++ b/internal/analysis/biz/profile.go
@@ -0,0 +1,127 @@
+package biz
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "strings"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+)
+
+type Profile struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspaceId"`
+ Name string `json:"name"`
+ CurrentVersion int32 `json:"currentVersion"`
+ DimensionKey string `json:"dimensionKey"`
+ Definition json.RawMessage `json:"definition"`
+ CreatedBy uuid.UUID `json:"createdBy"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ VersionCreatedAt time.Time `json:"versionCreatedAt"`
+}
+
+type ProfileDefinition struct {
+ DimensionKey string `json:"dimensionKey"`
+ TimeoutSeconds int64 `json:"timeoutSeconds"`
+ Analyzer struct {
+ Key string `json:"key"`
+ Required bool `json:"required"`
+ } `json:"analyzer"`
+}
+
+func DefaultProfileDefinition() json.RawMessage {
+ return json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":300,"analyzer":{"key":"scc","required":true}}`)
+}
+
+func normalizeProfileDefinition(raw json.RawMessage) (json.RawMessage, string, error) {
+ decoder := json.NewDecoder(bytes.NewReader(raw))
+ decoder.DisallowUnknownFields()
+ var definition ProfileDefinition
+ if err := decoder.Decode(&definition); err != nil {
+ return nil, "", fault.New(fault.Invalid, "analysis.profile_definition_invalid", "Analysis profile definition is invalid")
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ return nil, "", fault.New(fault.Invalid, "analysis.profile_definition_invalid", "Analysis profile definition is invalid")
+ }
+ if definition.DimensionKey != "code_scale" || definition.Analyzer.Key != "scc" || !definition.Analyzer.Required {
+ return nil, "", fault.New(fault.Invalid, "analysis.profile_definition_unsupported", "Analysis profile definition is unsupported")
+ }
+ if definition.TimeoutSeconds < 1 || definition.TimeoutSeconds > 86400 {
+ return nil, "", fault.New(fault.Invalid, "analysis.profile_timeout_invalid", "Analysis profile timeout must be between 1 and 86400 seconds")
+ }
+ encoded, err := json.Marshal(definition)
+ if err != nil {
+ return nil, "", err
+ }
+
+ return encoded, definition.DimensionKey, nil
+}
+
+func (s *Service) Profiles(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Profile], error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return pagination.Page[Profile]{}, err
+ }
+
+ return s.store.ListProfiles(ctx, workspaceID, page)
+}
+
+func (s *Service) CreateProfile(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, name string, definition json.RawMessage) (Profile, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return Profile{}, err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return Profile{}, fault.New(fault.Invalid, "analysis.profile_name_required", "Analysis profile name is required")
+ }
+ definition, dimensionKey, err := normalizeProfileDefinition(definition)
+ if err != nil {
+ return Profile{}, err
+ }
+
+ return s.store.CreateProfile(ctx, workspaceID, actor.UserID, name, dimensionKey, definition)
+}
+
+func (s *Service) UpdateProfile(ctx context.Context, actor auth.Actor, workspaceID, profileID uuid.UUID, currentVersion int32, name string, definition json.RawMessage) (Profile, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return Profile{}, err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return Profile{}, fault.New(fault.Invalid, "analysis.profile_name_required", "Analysis profile name is required")
+ }
+ if currentVersion < 1 {
+ return Profile{}, fault.New(fault.Invalid, "analysis.profile_version_invalid", "Analysis profile version is invalid")
+ }
+ definition, dimensionKey, err := normalizeProfileDefinition(definition)
+ if err != nil {
+ return Profile{}, err
+ }
+ profile, err := s.store.UpdateProfile(ctx, workspaceID, actor.UserID, profileID, currentVersion, name, dimensionKey, definition)
+ if err != nil {
+ return Profile{}, err
+ }
+
+ return profile, nil
+}
+
+func (s *Service) ArchiveProfile(ctx context.Context, actor auth.Actor, workspaceID, profileID uuid.UUID) error {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return err
+ }
+ ok, err := s.store.ArchiveProfile(ctx, workspaceID, actor.UserID, profileID)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fault.New(fault.NotFound, "analysis.profile_not_found", "Analysis profile was not found")
+ }
+
+ return nil
+}
diff --git a/internal/analysis/biz/profile_test.go b/internal/analysis/biz/profile_test.go
new file mode 100644
index 0000000..9bdbbc6
--- /dev/null
+++ b/internal/analysis/biz/profile_test.go
@@ -0,0 +1,24 @@
+package biz
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestProfileDefinitionValidation(t *testing.T) {
+ definition, dimension, err := normalizeProfileDefinition(DefaultProfileDefinition())
+ if err != nil || dimension != "code_scale" || !json.Valid(definition) {
+ t.Fatalf("default definition = (%s, %q, %v)", definition, dimension, err)
+ }
+ invalid := []json.RawMessage{
+ json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":0,"analyzer":{"key":"scc","required":true}}`),
+ json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":10,"analyzer":{"key":"other","required":true}}`),
+ json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":10,"analyzer":{"key":"scc","required":true},"unknown":true}`),
+ json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":10,"analyzer":{"key":"scc","required":true}} {}`),
+ }
+ for _, candidate := range invalid {
+ if _, _, err := normalizeProfileDefinition(candidate); err == nil {
+ t.Fatalf("invalid profile definition was accepted: %s", candidate)
+ }
+ }
+}
diff --git a/internal/analysis/biz/service.go b/internal/analysis/biz/service.go
new file mode 100644
index 0000000..f5dfb4a
--- /dev/null
+++ b/internal/analysis/biz/service.go
@@ -0,0 +1,170 @@
+package biz
+
+import (
+ "context"
+ "fmt"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ "github.com/fuchencong/mooncode/pkg/analyzer/scc"
+ "github.com/google/uuid"
+)
+
+type Authorizer interface {
+ Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error)
+}
+type Repositories interface {
+ Get(context.Context, uuid.UUID, uuid.UUID) (repository.Repository, error)
+ Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (repository.Snapshot, error)
+}
+
+type Service struct {
+ store Store
+ authorizer Authorizer
+ repositories Repositories
+ canceller Canceller
+}
+
+func NewService(store Store, authorizer Authorizer, repositories Repositories, canceller Canceller) *Service {
+ return &Service{store: store, authorizer: authorizer, repositories: repositories, canceller: canceller}
+}
+
+type CreateInput struct {
+ SnapshotID uuid.UUID
+ ProfileID uuid.UUID
+}
+
+func (s *Service) Create(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, input CreateInput) (Run, error) {
+ return s.create(ctx, actor, workspaceID, repositoryID, input, 1, nil, nil)
+}
+
+type frozenProfile struct {
+ ID uuid.UUID
+ Version string
+ DimensionKey string
+ Snapshot []byte
+}
+
+func (s *Service) create(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, input CreateInput, attempt int32, rerunOf *uuid.UUID, frozen *frozenProfile) (Run, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Run{}, err
+ }
+ repo, err := s.repositories.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ return Run{}, err
+ }
+ if repo.ArchivedAt != nil || (repo.Status != "ready" && repo.Status != "syncing") {
+ return Run{}, fault.New(fault.Conflict, "analysis.repository_unavailable", "Repository is not available for analysis")
+ }
+ var snapshot repository.Snapshot
+ if input.SnapshotID == uuid.Nil {
+ if repo.CurrentSnapshot == nil {
+ return Run{}, fault.New(fault.Conflict, "analysis.snapshot_required", "Repository has no available commit snapshot")
+ }
+ snapshot = *repo.CurrentSnapshot
+ } else {
+ snapshot, err = s.repositories.Snapshot(ctx, workspaceID, repositoryID, input.SnapshotID)
+ if err != nil {
+ return Run{}, err
+ }
+ }
+ if snapshot.SourceState != "available" || snapshot.CommitSHA == "" {
+ return Run{}, fault.New(fault.Conflict, "analysis.snapshot_unavailable", "Commit snapshot is not available for analysis")
+ }
+ if frozen == nil {
+ var profile Profile
+ if input.ProfileID == uuid.Nil {
+ profile, err = s.store.DefaultProfile(ctx, workspaceID)
+ } else {
+ profile, err = s.store.Profile(ctx, workspaceID, input.ProfileID)
+ }
+ if err != nil {
+ return Run{}, err
+ }
+ frozen = &frozenProfile{
+ ID: profile.ID,
+ Version: fmt.Sprintf("v%d", profile.CurrentVersion),
+ DimensionKey: profile.DimensionKey,
+ Snapshot: append([]byte(nil), profile.Definition...),
+ }
+ }
+
+ run := Run{
+ ID: uuid.New(), WorkspaceID: workspaceID, RepositoryID: repositoryID,
+ SnapshotID: snapshot.ID, CommitSHA: snapshot.CommitSHA, RequestedBy: actor.UserID,
+ DimensionKey: frozen.DimensionKey, ProfileID: frozen.ID, ProfileVersion: frozen.Version,
+ ProfileSnapshot: append([]byte(nil), frozen.Snapshot...), AnalyzerVersion: scc.Version,
+ IdempotencyKey: idempotencyKey(workspaceID, repositoryID, snapshot.ID, frozen.ID, frozen.DimensionKey, frozen.Version, scc.Version, frozen.Snapshot),
+ Attempt: attempt, RerunOf: rerunOf, Status: "queued", Stage: "queued",
+ }
+ run, err = s.store.Create(ctx, run)
+ if err != nil {
+ return Run{}, err
+ }
+ return run, nil
+}
+
+func (s *Service) Get(ctx context.Context, actor auth.Actor, workspaceID, runID uuid.UUID) (Run, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Run{}, err
+ }
+
+ return s.store.Get(ctx, workspaceID, runID)
+}
+
+func (s *Service) Report(ctx context.Context, actor auth.Actor, workspaceID, reportID uuid.UUID) (Report, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Report{}, err
+ }
+
+ return s.store.Report(ctx, workspaceID, reportID)
+}
+
+func (s *Service) List(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[Run], error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return pagination.Page[Run]{}, err
+ }
+
+ return s.store.List(ctx, workspaceID, repositoryID, page)
+}
+
+func (s *Service) Cancel(ctx context.Context, actor auth.Actor, workspaceID, runID uuid.UUID) error {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return err
+ }
+ workflowRunID, ok, err := s.store.Cancel(ctx, workspaceID, runID)
+ if err != nil || !ok {
+ return fault.New(fault.Conflict, "analysis.not_cancellable", "Analysis cannot be cancelled")
+ }
+ if workflowRunID != "" {
+ _ = s.canceller.CancelAnalysis(ctx, workflowRunID)
+ }
+
+ return nil
+}
+
+func (s *Service) Retry(ctx context.Context, actor auth.Actor, workspaceID, runID uuid.UUID) (Run, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Run{}, err
+ }
+ previous, err := s.store.Get(ctx, workspaceID, runID)
+ if err != nil {
+ return Run{}, err
+ }
+ if (previous.Status != "failed" && previous.Status != "cancelled") || !previous.Retryable {
+ return Run{}, fault.New(fault.Conflict, "analysis.not_retryable", "Analysis cannot be retried")
+ }
+ return s.create(
+ ctx,
+ actor,
+ workspaceID,
+ previous.RepositoryID,
+ CreateInput{SnapshotID: previous.SnapshotID, ProfileID: previous.ProfileID},
+ previous.Attempt+1,
+ &previous.ID,
+ &frozenProfile{ID: previous.ProfileID, Version: previous.ProfileVersion, DimensionKey: previous.DimensionKey, Snapshot: previous.ProfileSnapshot},
+ )
+}
diff --git a/internal/analysis/biz/service_test.go b/internal/analysis/biz/service_test.go
new file mode 100644
index 0000000..7fecf86
--- /dev/null
+++ b/internal/analysis/biz/service_test.go
@@ -0,0 +1,198 @@
+package biz
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ "github.com/fuchencong/mooncode/pkg/analyzer/scc"
+ "github.com/google/uuid"
+)
+
+type analysisAuthorizer struct {
+ err error
+}
+
+func (a analysisAuthorizer) Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) {
+ return identity.Membership{Role: "member"}, a.err
+}
+
+type analysisStore struct {
+ getCalled bool
+ getRun Run
+ created Run
+ profile Profile
+ cancelWorkflowRunID string
+}
+
+func (s *analysisStore) Create(_ context.Context, run Run) (Run, error) {
+ s.created = run
+
+ return run, nil
+}
+func (s *analysisStore) Get(context.Context, uuid.UUID, uuid.UUID) (Run, error) {
+ s.getCalled = true
+
+ return s.getRun, nil
+}
+func (*analysisStore) List(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Run], error) {
+ return pagination.Page[Run]{}, nil
+}
+func (*analysisStore) LoadWork(context.Context, uuid.UUID) (WorkItem, error) { return WorkItem{}, nil }
+func (*analysisStore) Start(context.Context, uuid.UUID) (Run, error) { return Run{}, nil }
+func (*analysisStore) SetStage(context.Context, uuid.UUID, string) (bool, error) {
+ return true, nil
+}
+func (*analysisStore) Finish(context.Context, uuid.UUID, json.RawMessage, json.RawMessage, Execution) (Run, error) {
+ return Run{}, nil
+}
+func (*analysisStore) Fail(context.Context, uuid.UUID, Failure) error { return nil }
+func (s *analysisStore) Cancel(context.Context, uuid.UUID, uuid.UUID) (string, bool, error) {
+ return s.cancelWorkflowRunID, true, nil
+}
+func (*analysisStore) Report(context.Context, uuid.UUID, uuid.UUID) (Report, error) {
+ return Report{}, nil
+}
+func (*analysisStore) ListProfiles(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Profile], error) {
+ return pagination.Page[Profile]{}, nil
+}
+func (s *analysisStore) Profile(context.Context, uuid.UUID, uuid.UUID) (Profile, error) {
+ return s.profile, nil
+}
+func (s *analysisStore) DefaultProfile(context.Context, uuid.UUID) (Profile, error) {
+ return s.profile, nil
+}
+func (*analysisStore) CreateProfile(context.Context, uuid.UUID, uuid.UUID, string, string, json.RawMessage) (Profile, error) {
+ return Profile{}, nil
+}
+func (*analysisStore) UpdateProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, int32, string, string, json.RawMessage) (Profile, error) {
+ return Profile{}, nil
+}
+func (*analysisStore) ArchiveProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error) {
+ return true, nil
+}
+
+type analysisCanceller struct {
+ called *string
+}
+
+func (c analysisCanceller) CancelAnalysis(_ context.Context, workflowRunID string) error {
+ if c.called != nil {
+ *c.called = workflowRunID
+ }
+
+ return nil
+}
+
+type analysisRepositories struct {
+ repository repository.Repository
+ snapshot repository.Snapshot
+}
+
+func (r analysisRepositories) Get(context.Context, uuid.UUID, uuid.UUID) (repository.Repository, error) {
+ return r.repository, nil
+}
+
+func (r analysisRepositories) Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (repository.Snapshot, error) {
+ return r.snapshot, nil
+}
+
+func TestRetryAuthorizesWorkspaceBeforeLoadingRun(t *testing.T) {
+ want := errors.New("workspace permission denied")
+ store := &analysisStore{}
+ service := NewService(store, analysisAuthorizer{err: want}, nil, analysisCanceller{})
+
+ _, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New())
+ if !errors.Is(err, want) {
+ t.Fatalf("Retry() error = %v, want %v", err, want)
+ }
+ if store.getCalled {
+ t.Fatal("analysis run was loaded before workspace authorization")
+ }
+}
+
+func TestCancelUsesWorkflowIDReturnedByAtomicCancellation(t *testing.T) {
+ store := &analysisStore{cancelWorkflowRunID: "current-hatchet-run"}
+ var cancelled string
+ service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{called: &cancelled})
+
+ if err := service.Cancel(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New()); err != nil {
+ t.Fatal(err)
+ }
+ if cancelled != store.cancelWorkflowRunID {
+ t.Fatalf("cancelled workflow = %q, want %q", cancelled, store.cancelWorkflowRunID)
+ }
+}
+
+func TestCreateFreezesSelectedSnapshotAndCommit(t *testing.T) {
+ workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New()
+ profileID := uuid.New()
+ store := &analysisStore{profile: Profile{ID: profileID, CurrentVersion: 1, DimensionKey: "code_scale", Definition: DefaultProfileDefinition()}}
+ repositories := analysisRepositories{
+ repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, Status: "ready"},
+ snapshot: repository.Snapshot{ID: snapshotID, RepositoryID: repositoryID, CommitSHA: "0123456789012345678901234567890123456789", SourceState: "available"},
+ }
+ service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{})
+
+ run, err := service.Create(context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, repositoryID, CreateInput{SnapshotID: snapshotID, ProfileID: profileID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if run.SnapshotID != snapshotID || run.CommitSHA != repositories.snapshot.CommitSHA || store.created.CommitSHA != repositories.snapshot.CommitSHA {
+ t.Fatalf("analysis target was not frozen: %#v", run)
+ }
+ if run.Attempt != 1 || len(run.IdempotencyKey) != 64 || run.RerunOf != nil {
+ t.Fatalf("initial analysis idempotency metadata is invalid: %#v", run)
+ }
+ if run.ProfileID != profileID || run.ProfileVersion != "v1" || string(run.ProfileSnapshot) != string(store.profile.Definition) {
+ t.Fatalf("analysis profile was not frozen: %#v", run)
+ }
+}
+
+func TestRetryKeepsOriginalSnapshot(t *testing.T) {
+ workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New()
+ commitSHA := "0123456789012345678901234567890123456789"
+ previousID := uuid.New()
+ profileID := uuid.New()
+ profileSnapshot := DefaultProfileDefinition()
+ store := &analysisStore{getRun: Run{ID: previousID, RepositoryID: repositoryID, SnapshotID: snapshotID, CommitSHA: commitSHA, DimensionKey: "code_scale", ProfileID: profileID, ProfileVersion: "v1", ProfileSnapshot: profileSnapshot, AnalyzerVersion: "scc-old", Status: "failed", Retryable: true, Attempt: 1}}
+ repositories := analysisRepositories{
+ repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, Status: "ready"},
+ snapshot: repository.Snapshot{ID: snapshotID, RepositoryID: repositoryID, CommitSHA: commitSHA, SourceState: "available"},
+ }
+ service := NewService(store, analysisAuthorizer{}, repositories, analysisCanceller{})
+
+ run, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, uuid.New())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if run.SnapshotID != snapshotID || run.CommitSHA != commitSHA {
+ t.Fatalf("retry changed analysis target: %#v", run)
+ }
+ if run.Attempt != 2 || run.RerunOf == nil || *run.RerunOf != previousID {
+ t.Fatalf("retry did not create the next attempt: %#v", run)
+ }
+ if run.ProfileID != profileID || run.ProfileVersion != "v1" || string(run.ProfileSnapshot) != string(profileSnapshot) {
+ t.Fatalf("retry changed profile snapshot: %#v", run)
+ }
+ if run.AnalyzerVersion != scc.Version {
+ t.Fatalf("retry analyzer version = %q, want current %q", run.AnalyzerVersion, scc.Version)
+ }
+}
+
+func TestRetryRejectsNonRetryableFailure(t *testing.T) {
+ store := &analysisStore{getRun: Run{Status: "failed", Retryable: false}}
+ service := NewService(store, analysisAuthorizer{}, nil, analysisCanceller{})
+
+ _, err := service.Retry(context.Background(), auth.Actor{UserID: uuid.New()}, uuid.New(), uuid.New())
+ problem, ok := fault.From(err)
+ if !ok || problem.Code() != "analysis.not_retryable" {
+ t.Fatalf("Retry() error = %v, want analysis.not_retryable", err)
+ }
+}
diff --git a/internal/analysis/biz/store.go b/internal/analysis/biz/store.go
new file mode 100644
index 0000000..b9f7320
--- /dev/null
+++ b/internal/analysis/biz/store.go
@@ -0,0 +1,32 @@
+package biz
+
+import (
+ "context"
+ "encoding/json"
+
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+)
+
+type Store interface {
+ Create(context.Context, Run) (Run, error)
+ Get(context.Context, uuid.UUID, uuid.UUID) (Run, error)
+ List(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Run], error)
+ LoadWork(context.Context, uuid.UUID) (WorkItem, error)
+ Start(context.Context, uuid.UUID) (Run, error)
+ SetStage(context.Context, uuid.UUID, string) (bool, error)
+ Finish(context.Context, uuid.UUID, json.RawMessage, json.RawMessage, Execution) (Run, error)
+ Fail(context.Context, uuid.UUID, Failure) error
+ Cancel(context.Context, uuid.UUID, uuid.UUID) (string, bool, error)
+ Report(context.Context, uuid.UUID, uuid.UUID) (Report, error)
+ ListProfiles(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Profile], error)
+ Profile(context.Context, uuid.UUID, uuid.UUID) (Profile, error)
+ DefaultProfile(context.Context, uuid.UUID) (Profile, error)
+ CreateProfile(context.Context, uuid.UUID, uuid.UUID, string, string, json.RawMessage) (Profile, error)
+ UpdateProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, int32, string, string, json.RawMessage) (Profile, error)
+ ArchiveProfile(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error)
+}
+
+type Canceller interface {
+ CancelAnalysis(context.Context, string) error
+}
diff --git a/internal/analysis/data/audit_integration_test.go b/internal/analysis/data/audit_integration_test.go
new file mode 100644
index 0000000..0a57473
--- /dev/null
+++ b/internal/analysis/data/audit_integration_test.go
@@ -0,0 +1,102 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestCreateAnalysisPersistsAuditWithDispatch(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ actorID, workspaceID, repositoryID, snapshotID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ profileDefinition := analysis.DefaultProfileDefinition()
+ statements := []struct {
+ query string
+ args []any
+ }{
+ {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{actorID}},
+ {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Analysis audit',$2,$3)`, []any{workspaceID, "analysis-audit-" + workspaceID.String(), actorID}},
+ {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, actorID}},
+ {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, actorID}},
+ {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", actorID}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{snapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + snapshotID.String()}},
+ }
+ for _, statement := range statements {
+ if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ run := analysis.Run{
+ ID: uuid.New(),
+ WorkspaceID: workspaceID,
+ RepositoryID: repositoryID,
+ SnapshotID: snapshotID,
+ CommitSHA: strings.Repeat("a", 40),
+ RequestedBy: actorID,
+ DimensionKey: "code_scale",
+ ProfileID: profileID,
+ ProfileVersion: "v1",
+ ProfileSnapshot: profileDefinition,
+ AnalyzerVersion: "integration",
+ IdempotencyKey: strings.Repeat("c", 64),
+ Attempt: 1,
+ Status: "queued",
+ }
+ store := NewStore(pool)
+ created, err := store.Create(ctx, run)
+ if err != nil {
+ t.Fatal(err)
+ }
+ duplicate := run
+ duplicate.ID = uuid.New()
+ deduplicated, err := store.Create(ctx, duplicate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deduplicated.ID != created.ID {
+ t.Fatalf("duplicate analysis created run %s, want existing %s", deduplicated.ID, created.ID)
+ }
+
+ var eventWorkspaceID, eventActorID uuid.UUID
+ var action, resourceType, metadata string
+ if err := pool.QueryRow(ctx, `SELECT workspace_id,actor_user_id,action,resource_type,metadata::text FROM audit_events WHERE resource_id=$1`, run.ID).Scan(&eventWorkspaceID, &eventActorID, &action, &resourceType, &metadata); err != nil {
+ t.Fatal(err)
+ }
+ if eventWorkspaceID != workspaceID || eventActorID != actorID || action != audit.ActionAnalysisCreated || resourceType != audit.ResourceAnalysisRun {
+ t.Fatalf("unexpected analysis audit identity: workspace=%s actor=%s action=%q resource=%q", eventWorkspaceID, eventActorID, action, resourceType)
+ }
+ if strings.Contains(metadata, run.CommitSHA) {
+ t.Fatalf("analysis audit metadata unexpectedly contains commit payload: %s", metadata)
+ }
+
+ var dispatchCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='analysis_run' AND aggregate_id=$1`, run.ID).Scan(&dispatchCount); err != nil {
+ t.Fatal(err)
+ }
+ if dispatchCount != 1 {
+ t.Fatalf("workflow dispatch count = %d, want 1", dispatchCount)
+ }
+ var matchingRuns int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM analysis_runs WHERE workspace_id=$1 AND idempotency_key=$2 AND attempt=1`, workspaceID, run.IdempotencyKey).Scan(&matchingRuns); err != nil {
+ t.Fatal(err)
+ }
+ if matchingRuns != 1 {
+ t.Fatalf("idempotent analysis run count = %d, want 1", matchingRuns)
+ }
+}
diff --git a/internal/analysis/data/failure_integration_test.go b/internal/analysis/data/failure_integration_test.go
new file mode 100644
index 0000000..076ab05
--- /dev/null
+++ b/internal/analysis/data/failure_integration_test.go
@@ -0,0 +1,90 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "strings"
+ "testing"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestFailureAndCancellationPersistRetrySemantics(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID, repositoryID, snapshotID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ profileDefinition := analysis.DefaultProfileDefinition()
+ statements := []struct {
+ query string
+ args []any
+ }{
+ {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}},
+ {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Analysis failure',$2,$3)`, []any{workspaceID, "analysis-failure-" + workspaceID.String(), userID}},
+ {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}},
+ {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}},
+ {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{snapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + snapshotID.String()}},
+ }
+ for _, statement := range statements {
+ if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ insertRun := func(status string) uuid.UUID {
+ t.Helper()
+ id := uuid.New()
+ digest := sha256.Sum256(id[:])
+ _, err := pool.Exec(ctx, `INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,$10)`, id, workspaceID, repositoryID, snapshotID, strings.Repeat("a", 40), userID, profileID, profileDefinition, hex.EncodeToString(digest[:]), status)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return id
+ }
+
+ store := NewStore(pool)
+ failedID := insertRun("running")
+ failure := analysis.Failure{Stage: "analyze", Code: "analysis.analyzer_failed", Message: "Analyzer execution failed", Retryable: true}
+ if err := store.Fail(ctx, failedID, failure); err != nil {
+ t.Fatal(err)
+ }
+ failed, err := store.Get(ctx, workspaceID, failedID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if failed.Status != "failed" || failed.FailedStage != failure.Stage || failed.ErrorCode != failure.Code || failed.ErrorMessage != failure.Message || !failed.Retryable {
+ t.Fatalf("unexpected persisted analysis failure: %+v", failed)
+ }
+
+ cancelledID := insertRun("queued")
+ if _, err := pool.Exec(ctx, `UPDATE analysis_runs SET workflow_run_id='analysis-workflow-run' WHERE id=$1`, cancelledID); err != nil {
+ t.Fatal(err)
+ }
+ workflowRunID, ok, err := store.Cancel(ctx, workspaceID, cancelledID)
+ if err != nil || !ok {
+ t.Fatalf("Cancel() = (%v, %v)", ok, err)
+ }
+ if workflowRunID != "analysis-workflow-run" {
+ t.Fatalf("Cancel() workflow run ID = %q", workflowRunID)
+ }
+ cancelled, err := store.Get(ctx, workspaceID, cancelledID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cancelled.Status != "cancelled" || !cancelled.Retryable || cancelled.FailedStage != "" || cancelled.ErrorCode != "" {
+ t.Fatalf("unexpected persisted cancellation: %+v", cancelled)
+ }
+}
diff --git a/internal/analysis/data/notification_integration_test.go b/internal/analysis/data/notification_integration_test.go
new file mode 100644
index 0000000..7a09b0e
--- /dev/null
+++ b/internal/analysis/data/notification_integration_test.go
@@ -0,0 +1,148 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestFinishCreatesNotificationAndDispatchAtomically(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID, repositoryID, snapshotID, runID, channelID, unsubscribedChannelID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ profileDefinition := analysis.DefaultProfileDefinition()
+ statements := []struct {
+ query string
+ args []any
+ }{
+ {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}},
+ {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Notification test',$2,$3)`, []any{workspaceID, "notification-" + workspaceID.String(), userID}},
+ {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}},
+ {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}},
+ {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,author_name,authored_at,title,source_state) VALUES ($1,$2,$3,$4,$5,'Ada Lovelace',TIMESTAMPTZ '2026-07-01T02:03:04Z','Trace metadata','available')`, []any{snapshotID, repositoryID, "1234567890abcdef", "refs/heads/main", "refs/mooncode/snapshots/" + snapshotID.String()}},
+ {`UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, []any{repositoryID, snapshotID}},
+ {`INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status,stage,started_at) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,'running','analyze',clock_timestamp() - interval '250 milliseconds')`, []any{runID, workspaceID, repositoryID, snapshotID, "1234567890abcdef", userID, profileID, profileDefinition, strings.Repeat("d", 64)}},
+ {`INSERT INTO channels (id,workspace_id,type,name,enabled,secret_ciphertext,secret_nonce,key_version,config) VALUES ($1,$2,'feishu','team',true,$3,$4,1,$5)`, []any{channelID, workspaceID, []byte("ciphertext"), []byte("nonce"), json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"}}`)}},
+ {`INSERT INTO channels (id,workspace_id,type,name,enabled,secret_ciphertext,secret_nonce,key_version,config) VALUES ($1,$2,'feishu','unsubscribed',true,$3,$4,1,$5)`, []any{unsubscribedChannelID, workspaceID, []byte("ciphertext"), []byte("nonce"), json.RawMessage(`{"values":{"app_id":"app","receive_id":"other-chat"}}`)}},
+ {`INSERT INTO channel_subscriptions (id,workspace_id,channel_id,event_type) VALUES ($1,$2,$3,'analysis.succeeded')`, []any{uuid.New(), workspaceID, channelID}},
+ {`INSERT INTO channel_subscriptions (id,workspace_id,channel_id,event_type) VALUES ($1,$2,$3,'analysis.started')`, []any{uuid.New(), workspaceID, channelID}},
+ {`INSERT INTO channel_subscriptions (id,workspace_id,channel_id,event_type) VALUES ($1,$2,$3,'analysis.failed')`, []any{uuid.New(), workspaceID, channelID}},
+ }
+ for _, statement := range statements {
+ if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ store := NewStore(pool)
+ result := json.RawMessage(`{"summary":{"files":1,"code":10},"languages":[{"name":"Go"}]}`)
+ rawArtifact := json.RawMessage(`[{"Name":"Go","Code":10}]`)
+ run, err := store.Finish(ctx, runID, result, rawArtifact, analysis.Execution{Environment: "integration/test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if run.Status != "succeeded" || run.Report == nil || run.Report.CommitSHA != "1234567890abcdef" {
+ t.Fatalf("unexpected completed run %#v", run)
+ }
+ wantAuthoredAt := time.Date(2026, time.July, 1, 2, 3, 4, 0, time.UTC)
+ if run.Report.SourceRef != "refs/heads/main" || run.Report.CommitAuthor != "Ada Lovelace" || run.Report.CommitTitle != "Trace metadata" {
+ t.Fatalf("unexpected report commit metadata: %#v", run.Report)
+ }
+ if run.Report.CommitAuthoredAt == nil || !run.Report.CommitAuthoredAt.Equal(wantAuthoredAt) {
+ t.Fatalf("report authored time = %v, want %v", run.Report.CommitAuthoredAt, wantAuthoredAt)
+ }
+ if run.Report.ExecutionEnvironment != "integration/test" || run.Report.DurationMS < 0 {
+ t.Fatalf("unexpected report execution metadata: %#v", run.Report)
+ }
+ var frozenProfile analysis.ProfileDefinition
+ if err := json.Unmarshal(run.Report.ProfileSnapshot, &frozenProfile); err != nil {
+ t.Fatal(err)
+ }
+ if run.ProfileID != profileID || run.Report.ProfileID != profileID || run.ProfileVersion != "v1" || run.Report.ProfileVersion != run.ProfileVersion || frozenProfile.TimeoutSeconds != 300 {
+ t.Fatalf("run/report profile snapshot differs: run=%#v report=%#v", run, run.Report)
+ }
+ if run.StartedAt == nil || run.FinishedAt == nil || !run.Report.StartedAt.Equal(*run.StartedAt) || !run.Report.FinishedAt.Equal(*run.FinishedAt) {
+ t.Fatalf("run/report timestamps differ: run=%#v report=%#v", run, run.Report)
+ }
+ var storedRaw []byte
+ if err := pool.QueryRow(ctx, `SELECT raw_artifact FROM analysis_reports WHERE analysis_run_id=$1`, runID).Scan(&storedRaw); err != nil {
+ t.Fatal(err)
+ }
+ var storedArtifact, expectedArtifact any
+ if err := json.Unmarshal(storedRaw, &storedArtifact); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(rawArtifact, &expectedArtifact); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(storedArtifact, expectedArtifact) {
+ t.Fatalf("raw artifact = %s, want %s", storedRaw, rawArtifact)
+ }
+ var notificationCount, dispatchCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE analysis_run_id=$1 AND channel_id=$2 AND status='queued'`, runID, channelID).Scan(¬ificationCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='notification' AND workflow_name='notification-delivery' AND aggregate_id IN (SELECT id FROM notifications WHERE analysis_run_id=$1)`, runID).Scan(&dispatchCount); err != nil {
+ t.Fatal(err)
+ }
+ if notificationCount != 1 || dispatchCount != 1 {
+ t.Fatalf("expected one notification and dispatch, got %d and %d", notificationCount, dispatchCount)
+ }
+ var unsubscribedCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE analysis_run_id=$1 AND channel_id=$2`, runID, unsubscribedChannelID).Scan(&unsubscribedCount); err != nil {
+ t.Fatal(err)
+ }
+ if unsubscribedCount != 0 {
+ t.Fatalf("unsubscribed channel received %d notifications", unsubscribedCount)
+ }
+
+ startedRunID := uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,'queued')`, startedRunID, workspaceID, repositoryID, snapshotID, "1234567890abcdef", userID, profileID, profileDefinition, strings.Repeat("e", 64)); err != nil {
+ t.Fatal(err)
+ }
+ started, err := store.Start(ctx, startedRunID)
+ if err != nil || started.Status != "running" {
+ t.Fatalf("Start() = (%#v, %v)", started, err)
+ }
+
+ failedRunID := uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO analysis_runs (id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,status,stage,started_at) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,'running','analyze',now())`, failedRunID, workspaceID, repositoryID, snapshotID, "1234567890abcdef", userID, profileID, profileDefinition, strings.Repeat("f", 64)); err != nil {
+ t.Fatal(err)
+ }
+ failure := analysis.Failure{Stage: "analyze", Code: "analysis.analyzer_failed", Message: "Analyzer execution failed", Retryable: true}
+ if err := store.Fail(ctx, failedRunID, failure); err != nil {
+ t.Fatal(err)
+ }
+
+ for event, eventRunID := range map[string]uuid.UUID{
+ "analysis.started": startedRunID,
+ "analysis.failed": failedRunID,
+ } {
+ var notifications, dispatches int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM notifications WHERE analysis_run_id=$1 AND channel_id=$2 AND event_type=$3`, eventRunID, channelID, event).Scan(¬ifications); err != nil {
+ t.Fatal(err)
+ }
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='notification' AND aggregate_id IN (SELECT id FROM notifications WHERE analysis_run_id=$1 AND event_type=$2)`, eventRunID, event).Scan(&dispatches); err != nil {
+ t.Fatal(err)
+ }
+ if notifications != 1 || dispatches != 1 {
+ t.Fatalf("event %q notifications/dispatches = %d/%d, want 1/1", event, notifications, dispatches)
+ }
+ }
+}
diff --git a/internal/analysis/data/options.go b/internal/analysis/data/options.go
new file mode 100644
index 0000000..6a16753
--- /dev/null
+++ b/internal/analysis/data/options.go
@@ -0,0 +1,11 @@
+package data
+
+type Option func(*Store)
+
+func WithMaxConcurrentRunsPerWorkspace(limit int64) Option {
+ return func(store *Store) {
+ if limit > 0 {
+ store.maxConcurrentPerWorkspace = limit
+ }
+ }
+}
diff --git a/internal/analysis/data/profile_integration_test.go b/internal/analysis/data/profile_integration_test.go
new file mode 100644
index 0000000..6437cb7
--- /dev/null
+++ b/internal/analysis/data/profile_integration_test.go
@@ -0,0 +1,82 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "testing"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestAnalysisProfileVersionsAreImmutableAndLastProfileIsRetained(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ actorID, workspaceID := uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, actorID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Profile integration',$2,$3)`, workspaceID, "profile-"+workspaceID.String(), actorID); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ first, err := store.CreateProfile(ctx, workspaceID, actorID, "Code scale", "code_scale", analysis.DefaultProfileDefinition())
+ if err != nil {
+ t.Fatal(err)
+ }
+ updatedDefinition := json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":120,"analyzer":{"key":"scc","required":true}}`)
+ updated, err := store.UpdateProfile(ctx, workspaceID, actorID, first.ID, first.CurrentVersion, "Code scale", "code_scale", updatedDefinition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var definition analysis.ProfileDefinition
+ if err := json.Unmarshal(updated.Definition, &definition); err != nil {
+ t.Fatal(err)
+ }
+ if updated.CurrentVersion != 2 || definition.TimeoutSeconds != 120 {
+ t.Fatalf("unexpected profile version: %#v", updated)
+ }
+ var versions int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM analysis_profile_versions WHERE profile_id=$1`, first.ID).Scan(&versions); err != nil {
+ t.Fatal(err)
+ }
+ if versions != 2 {
+ t.Fatalf("profile version count = %d, want 2", versions)
+ }
+ if _, err := store.UpdateProfile(ctx, workspaceID, actorID, first.ID, 1, "stale", "code_scale", updatedDefinition); err == nil {
+ t.Fatal("expected stale profile version to fail")
+ } else if problem, ok := fault.From(err); !ok || problem.Code() != "analysis.profile_version_stale" {
+ t.Fatalf("stale update error = %v", err)
+ }
+ if _, err := store.ArchiveProfile(ctx, workspaceID, actorID, first.ID); err == nil {
+ t.Fatal("expected last active profile archive to fail")
+ } else if problem, ok := fault.From(err); !ok || problem.Code() != "analysis.profile_last_active" {
+ t.Fatalf("last profile archive error = %v", err)
+ }
+ second, err := store.CreateProfile(ctx, workspaceID, actorID, "Short", "code_scale", updatedDefinition)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if archived, err := store.ArchiveProfile(ctx, workspaceID, actorID, second.ID); err != nil || !archived {
+ t.Fatalf("ArchiveProfile() = (%v, %v)", archived, err)
+ }
+ var auditEvents int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_events WHERE workspace_id=$1 AND actor_user_id=$2 AND resource_type=$3 AND resource_id IN ($4,$5)`, workspaceID, actorID, audit.ResourceAnalysisProfile, first.ID, second.ID).Scan(&auditEvents); err != nil {
+ t.Fatal(err)
+ }
+ if auditEvents != 4 {
+ t.Fatalf("analysis profile audit event count = %d, want 4", auditEvents)
+ }
+}
diff --git a/internal/analysis/data/profile_store.go b/internal/analysis/data/profile_store.go
new file mode 100644
index 0000000..8cd2bf5
--- /dev/null
+++ b/internal/analysis/data/profile_store.go
@@ -0,0 +1,197 @@
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/data/pagecursor"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+func (s *Store) ListProfiles(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[analysis.Profile], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListAnalysisProfiles(ctx, sqlc.ListAnalysisProfilesParams{
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[analysis.Profile]{}, err
+ }
+ items := make([]analysis.Profile, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, analysis.Profile{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, Name: row.Name, CurrentVersion: row.CurrentVersion,
+ DimensionKey: row.DimensionKey, Definition: row.Definition, CreatedBy: row.CreatedBy,
+ CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, VersionCreatedAt: row.VersionCreatedAt.Time,
+ })
+ }
+
+ return pagination.Build(items, page.Limit, func(item analysis.Profile) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) Profile(ctx context.Context, workspaceID, profileID uuid.UUID) (analysis.Profile, error) {
+ row, err := s.queries.GetAnalysisProfile(ctx, sqlc.GetAnalysisProfileParams{ID: profileID, WorkspaceID: workspaceID})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return analysis.Profile{}, fault.New(fault.NotFound, "analysis.profile_not_found", "Analysis profile was not found")
+ }
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+
+ return profileFromGet(row), nil
+}
+
+func (s *Store) DefaultProfile(ctx context.Context, workspaceID uuid.UUID) (analysis.Profile, error) {
+ row, err := s.queries.GetDefaultAnalysisProfile(ctx, workspaceID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return analysis.Profile{}, fault.New(fault.Conflict, "analysis.profile_required", "Workspace has no available analysis profile")
+ }
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+
+ return analysis.Profile{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, Name: row.Name, CurrentVersion: row.CurrentVersion,
+ DimensionKey: row.DimensionKey, Definition: row.Definition, CreatedBy: row.CreatedBy,
+ CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, VersionCreatedAt: row.VersionCreatedAt.Time,
+ }, nil
+}
+
+func (s *Store) CreateProfile(ctx context.Context, workspaceID, actorID uuid.UUID, name, dimensionKey string, definition json.RawMessage) (analysis.Profile, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ queries := s.queries.WithTx(tx)
+ profileID := uuid.New()
+ row, err := queries.CreateAnalysisProfile(ctx, sqlc.CreateAnalysisProfileParams{
+ ID: profileID, WorkspaceID: workspaceID, Name: name, CreatedBy: actorID,
+ })
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+ version, err := queries.CreateAnalysisProfileVersion(ctx, sqlc.CreateAnalysisProfileVersionParams{
+ ID: uuid.New(), WorkspaceID: workspaceID, ProfileID: profileID, Version: 1,
+ DimensionKey: dimensionKey, Definition: definition, CreatedBy: actorID,
+ })
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+ if err = audit.Record(ctx, queries, audit.Event{
+ WorkspaceID: workspaceID, ActorUserID: actorID, Action: audit.ActionAnalysisProfileCreated,
+ Resource: audit.ResourceAnalysisProfile, ResourceID: profileID, Metadata: map[string]any{"version": 1, "dimensionKey": dimensionKey},
+ }); err != nil {
+ return analysis.Profile{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return analysis.Profile{}, err
+ }
+
+ return profileFromRows(row, version), nil
+}
+
+func (s *Store) UpdateProfile(ctx context.Context, workspaceID, actorID, profileID uuid.UUID, currentVersion int32, name, dimensionKey string, definition json.RawMessage) (analysis.Profile, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ queries := s.queries.WithTx(tx)
+ row, err := queries.AdvanceAnalysisProfile(ctx, sqlc.AdvanceAnalysisProfileParams{
+ ID: profileID, WorkspaceID: workspaceID, Name: name, CurrentVersion: currentVersion,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return analysis.Profile{}, fault.New(fault.Conflict, "analysis.profile_version_stale", "Analysis profile has changed; reload and try again")
+ }
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+ version, err := queries.CreateAnalysisProfileVersion(ctx, sqlc.CreateAnalysisProfileVersionParams{
+ ID: uuid.New(), WorkspaceID: workspaceID, ProfileID: profileID, Version: row.CurrentVersion,
+ DimensionKey: dimensionKey, Definition: definition, CreatedBy: actorID,
+ })
+ if err != nil {
+ return analysis.Profile{}, err
+ }
+ if err = audit.Record(ctx, queries, audit.Event{
+ WorkspaceID: workspaceID, ActorUserID: actorID, Action: audit.ActionAnalysisProfileVersionCreated,
+ Resource: audit.ResourceAnalysisProfile, ResourceID: profileID, Metadata: map[string]any{"version": row.CurrentVersion, "dimensionKey": dimensionKey},
+ }); err != nil {
+ return analysis.Profile{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return analysis.Profile{}, err
+ }
+
+ return profileFromRows(row, version), nil
+}
+
+func (s *Store) ArchiveProfile(ctx context.Context, workspaceID, actorID, profileID uuid.UUID) (bool, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ queries := s.queries.WithTx(tx)
+ active, err := queries.LockActiveAnalysisProfiles(ctx, workspaceID)
+ if err != nil {
+ return false, err
+ }
+ found := false
+ for _, id := range active {
+ if id == profileID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ return false, nil
+ }
+ if len(active) <= 1 {
+ return false, fault.New(fault.Conflict, "analysis.profile_last_active", "Workspace must keep at least one active analysis profile")
+ }
+ count, err := queries.ArchiveAnalysisProfile(ctx, sqlc.ArchiveAnalysisProfileParams{ID: profileID, WorkspaceID: workspaceID})
+ if err != nil || count == 0 {
+ return false, err
+ }
+ if err = audit.Record(ctx, queries, audit.Event{
+ WorkspaceID: workspaceID, ActorUserID: actorID, Action: audit.ActionAnalysisProfileArchived,
+ Resource: audit.ResourceAnalysisProfile, ResourceID: profileID,
+ }); err != nil {
+ return false, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
+
+func profileFromGet(row sqlc.GetAnalysisProfileRow) analysis.Profile {
+ return analysis.Profile{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, Name: row.Name, CurrentVersion: row.CurrentVersion,
+ DimensionKey: row.DimensionKey, Definition: row.Definition, CreatedBy: row.CreatedBy,
+ CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, VersionCreatedAt: row.VersionCreatedAt.Time,
+ }
+}
+
+func profileFromRows(profile sqlc.AnalysisProfile, version sqlc.AnalysisProfileVersion) analysis.Profile {
+ return analysis.Profile{
+ ID: profile.ID, WorkspaceID: profile.WorkspaceID, Name: profile.Name, CurrentVersion: profile.CurrentVersion,
+ DimensionKey: version.DimensionKey, Definition: version.Definition, CreatedBy: profile.CreatedBy,
+ CreatedAt: profile.CreatedAt.Time, UpdatedAt: profile.UpdatedAt.Time, VersionCreatedAt: version.CreatedAt.Time,
+ }
+}
diff --git a/internal/analysis/data/quota_integration_test.go b/internal/analysis/data/quota_integration_test.go
new file mode 100644
index 0000000..f45dd6b
--- /dev/null
+++ b/internal/analysis/data/quota_integration_test.go
@@ -0,0 +1,69 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "os"
+ "strings"
+ "testing"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/fuchencong/mooncode/pkg/analyzer/scc"
+ "github.com/google/uuid"
+)
+
+func TestAnalysisConcurrencyQuotaPreservesIdempotency(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID, repositoryID, snapshotID, profileID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ profileDefinition := analysis.DefaultProfileDefinition()
+ statements := []struct {
+ query string
+ args []any
+ }{
+ {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}},
+ {`INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Analysis quota',$2,$3)`, []any{workspaceID, "analysis-quota-" + workspaceID.String(), userID}},
+ {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}},
+ {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}},
+ {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{snapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + snapshotID.String()}},
+ }
+ for _, statement := range statements {
+ if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
+ t.Fatal(err)
+ }
+ }
+ store := NewStore(pool, WithMaxConcurrentRunsPerWorkspace(1))
+ newRun := func(key string) analysis.Run {
+ return analysis.Run{
+ ID: uuid.New(), WorkspaceID: workspaceID, RepositoryID: repositoryID, SnapshotID: snapshotID,
+ CommitSHA: strings.Repeat("a", 40), RequestedBy: userID, DimensionKey: "code_scale",
+ ProfileID: profileID, ProfileVersion: "v1", ProfileSnapshot: profileDefinition,
+ AnalyzerVersion: scc.Version, IdempotencyKey: key, Attempt: 1, Status: "queued", Stage: "queued",
+ }
+ }
+ firstInput := newRun(strings.Repeat("b", 64))
+ first, err := store.Create(ctx, firstInput)
+ if err != nil {
+ t.Fatal(err)
+ }
+ duplicateInput := firstInput
+ duplicateInput.ID = uuid.New()
+ duplicate, err := store.Create(ctx, duplicateInput)
+ if err != nil || duplicate.ID != first.ID {
+ t.Fatalf("duplicate Create() = (%#v, %v), want run %s", duplicate, err, first.ID)
+ }
+ if _, err := store.Create(ctx, newRun(strings.Repeat("c", 64))); err == nil {
+ t.Fatal("expected analysis concurrency quota error")
+ } else if problem, ok := fault.From(err); !ok || problem.Code() != "analysis.workspace_concurrency_exceeded" {
+ t.Fatalf("quota error = %v", err)
+ }
+}
diff --git a/internal/analysis/data/store.go b/internal/analysis/data/store.go
new file mode 100644
index 0000000..b74d843
--- /dev/null
+++ b/internal/analysis/data/store.go
@@ -0,0 +1,427 @@
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ channel "github.com/fuchencong/mooncode/internal/channel/biz"
+ "github.com/fuchencong/mooncode/internal/data/pagecursor"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ workflow "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ pool *pgxpool.Pool
+ queries *sqlc.Queries
+ maxConcurrentPerWorkspace int64
+}
+
+func NewStore(pool *pgxpool.Pool, options ...Option) *Store {
+ store := &Store{pool: pool, queries: sqlc.New(pool), maxConcurrentPerWorkspace: 10}
+ for _, option := range options {
+ option(store)
+ }
+
+ return store
+}
+
+func (s *Store) Create(ctx context.Context, run analysis.Run) (analysis.Run, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ if _, err = q.LockWorkspaceQuota(ctx, run.WorkspaceID); err != nil {
+ return analysis.Run{}, err
+ }
+ existing, err := q.GetAnalysisRunByAttempt(ctx, sqlc.GetAnalysisRunByAttemptParams{
+ WorkspaceID: run.WorkspaceID, IdempotencyKey: run.IdempotencyKey, Attempt: run.Attempt,
+ })
+ if err == nil {
+ return mapRun(existing), nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return analysis.Run{}, err
+ }
+ sourceState, err := q.LockAnalysisSnapshot(ctx, sqlc.LockAnalysisSnapshotParams{
+ ID: run.SnapshotID, RepositoryID: run.RepositoryID, WorkspaceID: run.WorkspaceID,
+ })
+ if errors.Is(err, pgx.ErrNoRows) || (err == nil && sourceState != "available") {
+ return analysis.Run{}, fault.New(fault.Conflict, "analysis.snapshot_unavailable", "Commit snapshot is not available for analysis")
+ }
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ active, err := q.CountActiveAnalysisRuns(ctx, run.WorkspaceID)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ if active >= s.maxConcurrentPerWorkspace {
+ return analysis.Run{}, fault.New(fault.Conflict, "analysis.workspace_concurrency_exceeded", "Workspace analysis concurrency quota has been reached")
+ }
+ runOf := uuid.NullUUID{}
+ if run.RerunOf != nil {
+ runOf = uuid.NullUUID{UUID: *run.RerunOf, Valid: true}
+ }
+ row, err := q.CreateAnalysisRun(ctx, sqlc.CreateAnalysisRunParams{
+ ID: run.ID, WorkspaceID: run.WorkspaceID, RepositoryID: run.RepositoryID,
+ SnapshotID: run.SnapshotID, CommitSha: run.CommitSHA, RequestedBy: run.RequestedBy,
+ DimensionKey: run.DimensionKey, ProfileID: run.ProfileID, ProfileVersion: run.ProfileVersion,
+ ProfileSnapshot: run.ProfileSnapshot, AnalyzerVersion: run.AnalyzerVersion,
+ IdempotencyKey: run.IdempotencyKey, Attempt: run.Attempt, RerunOf: runOf,
+ })
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ dispatch, err := workflow.NewDispatch(
+ workflow.AggregateAnalysisRun,
+ run.ID,
+ workflow.WorkflowAnalysisRun,
+ workflow.Payload{AggregateID: run.ID, RepositoryID: run.RepositoryID},
+ )
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID, WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload}); err != nil {
+ return analysis.Run{}, err
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ WorkspaceID: run.WorkspaceID,
+ ActorUserID: run.RequestedBy,
+ Action: audit.ActionAnalysisCreated,
+ Resource: audit.ResourceAnalysisRun,
+ ResourceID: run.ID,
+ Metadata: map[string]any{
+ "repositoryId": run.RepositoryID,
+ "snapshotId": run.SnapshotID,
+ "dimensionKey": run.DimensionKey,
+ "attempt": run.Attempt,
+ },
+ }); err != nil {
+ return analysis.Run{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return analysis.Run{}, err
+ }
+
+ return mapRun(row), nil
+}
+
+func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (analysis.Run, error) {
+ row, err := s.queries.GetAnalysisRun(ctx, sqlc.GetAnalysisRunParams{ID: id, WorkspaceID: workspaceID})
+ if err != nil {
+ return analysis.Run{}, err
+ }
+
+ return s.attachReport(ctx, mapRun(row))
+}
+
+func (s *Store) List(ctx context.Context, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[analysis.Run], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListAnalysisRuns(ctx, sqlc.ListAnalysisRunsParams{
+ RepositoryID: repositoryID,
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[analysis.Run]{}, err
+ }
+ items := make([]analysis.Run, 0, len(rows))
+ for _, row := range rows {
+ item, attachErr := s.attachReport(ctx, mapRun(row))
+ if attachErr != nil {
+ return pagination.Page[analysis.Run]{}, attachErr
+ }
+ items = append(items, item)
+ }
+ return pagination.Build(items, page.Limit, func(item analysis.Run) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) LoadWork(ctx context.Context, id uuid.UUID) (analysis.WorkItem, error) {
+ row, err := s.queries.GetAnalysisRunWork(ctx, id)
+
+ return analysis.WorkItem{Run: mapRun(row), CommitSHA: row.CommitSha}, err
+}
+
+func (s *Store) Start(ctx context.Context, id uuid.UUID) (analysis.Run, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.StartAnalysisRun(ctx, id)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisStarted); err != nil {
+ return analysis.Run{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return analysis.Run{}, err
+ }
+
+ return mapRun(row), nil
+}
+func (s *Store) SetStage(ctx context.Context, id uuid.UUID, stage string) (bool, error) {
+ count, err := s.queries.SetAnalysisRunStage(ctx, sqlc.SetAnalysisRunStageParams{ID: id, Stage: stage})
+
+ return count == 1, err
+}
+func (s *Store) Finish(ctx context.Context, id uuid.UUID, result, rawArtifact json.RawMessage, execution analysis.Execution) (analysis.Run, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ work, err := q.GetAnalysisRunWork(ctx, id)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ if !work.StartedAt.Valid {
+ return analysis.Run{}, errors.New("analysis run has no start time")
+ }
+ snapshot, err := q.GetSnapshot(ctx, sqlc.GetSnapshotParams{
+ ID: work.SnapshotID,
+ RepositoryID: work.RepositoryID,
+ })
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ finishedAt, err := q.AnalysisClock(ctx)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ if !finishedAt.Valid {
+ return analysis.Run{}, errors.New("analysis clock returned no time")
+ }
+ durationMS := finishedAt.Time.Sub(work.StartedAt.Time).Milliseconds()
+ if durationMS < 0 {
+ durationMS = 0
+ }
+ if len(rawArtifact) == 0 {
+ rawArtifact = result
+ }
+ reportRow, err := q.CreateAnalysisReport(ctx, sqlc.CreateAnalysisReportParams{
+ ID: uuid.New(),
+ AnalysisRunID: work.ID,
+ WorkspaceID: work.WorkspaceID,
+ RepositoryID: work.RepositoryID,
+ SnapshotID: work.SnapshotID,
+ CommitSha: work.CommitSha,
+ SourceRef: snapshot.SourceRef,
+ CommitAuthorName: snapshot.AuthorName,
+ CommitAuthoredAt: snapshot.AuthoredAt,
+ CommitTitle: snapshot.Title,
+ DimensionKey: work.DimensionKey,
+ ProfileID: work.ProfileID,
+ ProfileVersion: work.ProfileVersion,
+ ProfileSnapshot: work.ProfileSnapshot,
+ AnalyzerVersion: work.AnalyzerVersion,
+ ExecutionEnvironment: execution.Environment,
+ StartedAt: work.StartedAt,
+ FinishedAt: finishedAt,
+ DurationMs: durationMS,
+ Result: result,
+ RawArtifact: rawArtifact,
+ })
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ row, err := q.FinishAnalysisRun(ctx, sqlc.FinishAnalysisRunParams{
+ ID: id,
+ ReportID: uuid.NullUUID{UUID: reportRow.ID, Valid: true},
+ FinishedAt: finishedAt,
+ })
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisSucceeded); err != nil {
+ return analysis.Run{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return analysis.Run{}, err
+ }
+
+ run := mapRun(row)
+ report := mapReport(reportRow)
+ run.Report = &report
+
+ return run, nil
+}
+func (s *Store) Fail(ctx context.Context, id uuid.UUID, failure analysis.Failure) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.FailAnalysisRun(ctx, sqlc.FailAnalysisRunParams{
+ ID: id,
+ Stage: failure.Stage,
+ ErrorCode: text(failure.Code),
+ ErrorMessage: text(failure.Message),
+ Retryable: failure.Retryable,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err = enqueueAnalysisNotifications(ctx, q, row, channel.EventAnalysisFailed); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+func (s *Store) Cancel(ctx context.Context, workspaceID, id uuid.UUID) (string, bool, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return "", false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ workflowRunID, err := q.CancelAnalysisRun(ctx, sqlc.CancelAnalysisRunParams{ID: id, WorkspaceID: workspaceID})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return "", false, nil
+ }
+ if err != nil {
+ return "", false, err
+ }
+ if err = q.CancelWorkflowDispatch(ctx, sqlc.CancelWorkflowDispatchParams{AggregateType: workflow.AggregateAnalysisRun, AggregateID: id}); err != nil {
+ return "", false, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return "", false, err
+ }
+
+ return workflowRunID.String, true, nil
+}
+
+func (s *Store) Report(ctx context.Context, workspaceID, id uuid.UUID) (analysis.Report, error) {
+ row, err := s.queries.GetAnalysisReport(ctx, sqlc.GetAnalysisReportParams{ID: id, WorkspaceID: workspaceID})
+
+ return mapReport(row), err
+}
+
+func (s *Store) attachReport(ctx context.Context, run analysis.Run) (analysis.Run, error) {
+ if run.ReportID == nil {
+ return run, nil
+ }
+ row, err := s.queries.GetAnalysisReportByRun(ctx, run.ID)
+ if err != nil {
+ return analysis.Run{}, err
+ }
+ report := mapReport(row)
+ run.Report = &report
+
+ return run, nil
+}
+
+func mapRun(row sqlc.AnalysisRun) analysis.Run {
+ run := analysis.Run{ID: row.ID, WorkspaceID: row.WorkspaceID, RepositoryID: row.RepositoryID, SnapshotID: row.SnapshotID, CommitSHA: row.CommitSha, RequestedBy: row.RequestedBy, DimensionKey: row.DimensionKey, ProfileID: row.ProfileID, ProfileVersion: row.ProfileVersion, ProfileSnapshot: row.ProfileSnapshot, AnalyzerVersion: row.AnalyzerVersion, IdempotencyKey: row.IdempotencyKey, Attempt: row.Attempt, Status: row.Status, Stage: row.Stage, WorkflowRunID: row.WorkflowRunID.String, FailedStage: row.FailedStage.String, ErrorCode: row.ErrorCode.String, ErrorMessage: row.ErrorMessage.String, Retryable: row.Retryable, CreatedAt: row.CreatedAt.Time, StartedAt: optionalTime(row.StartedAt), FinishedAt: optionalTime(row.FinishedAt)}
+ if row.RerunOf.Valid {
+ id := row.RerunOf.UUID
+ run.RerunOf = &id
+ }
+ if row.ReportID.Valid {
+ id := row.ReportID.UUID
+ run.ReportID = &id
+ }
+
+ return run
+}
+
+func mapReport(row sqlc.AnalysisReport) analysis.Report {
+ return analysis.Report{
+ ID: row.ID,
+ AnalysisRunID: row.AnalysisRunID,
+ WorkspaceID: row.WorkspaceID,
+ RepositoryID: row.RepositoryID,
+ SnapshotID: row.SnapshotID,
+ CommitSHA: row.CommitSha,
+ SourceRef: row.SourceRef,
+ CommitAuthor: row.CommitAuthorName.String,
+ CommitAuthoredAt: optionalTime(row.CommitAuthoredAt),
+ CommitTitle: row.CommitTitle.String,
+ DimensionKey: row.DimensionKey,
+ ProfileID: row.ProfileID,
+ ProfileVersion: row.ProfileVersion,
+ ProfileSnapshot: row.ProfileSnapshot,
+ AnalyzerVersion: row.AnalyzerVersion,
+ ExecutionEnvironment: row.ExecutionEnvironment,
+ StartedAt: row.StartedAt.Time,
+ FinishedAt: row.FinishedAt.Time,
+ DurationMS: row.DurationMs,
+ Result: row.Result,
+ RawArtifact: row.RawArtifact,
+ CreatedAt: row.CreatedAt.Time,
+ }
+}
+func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} }
+func optionalTime(value pgtype.Timestamptz) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Time
+ return &result
+}
+
+func enqueueAnalysisNotifications(ctx context.Context, queries *sqlc.Queries, run sqlc.AnalysisRun, event string) error {
+ channels, err := queries.ListSubscribedChannels(ctx, sqlc.ListSubscribedChannelsParams{
+ WorkspaceID: run.WorkspaceID,
+ EventType: event,
+ })
+ if err != nil {
+ return err
+ }
+ for _, target := range channels {
+ notificationID := uuid.New()
+ if _, err = queries.CreateNotification(ctx, sqlc.CreateNotificationParams{
+ ID: notificationID,
+ WorkspaceID: run.WorkspaceID,
+ AnalysisRunID: run.ID,
+ ChannelID: target.ID,
+ EventType: event,
+ }); err != nil {
+ return err
+ }
+ dispatch, dispatchErr := workflow.NewDispatch(
+ workflow.AggregateNotification,
+ notificationID,
+ workflow.WorkflowNotificationDelivery,
+ workflow.Payload{AggregateID: notificationID, RepositoryID: run.RepositoryID, ChannelID: target.ID},
+ )
+ if dispatchErr != nil {
+ return dispatchErr
+ }
+ if _, err = queries.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{
+ ID: dispatch.ID,
+ AggregateType: dispatch.AggregateType,
+ AggregateID: dispatch.AggregateID,
+ WorkflowName: dispatch.WorkflowName,
+ Payload: dispatch.Payload,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/internal/analysis/workflow/failure.go b/internal/analysis/workflow/failure.go
new file mode 100644
index 0000000..2b6269f
--- /dev/null
+++ b/internal/analysis/workflow/failure.go
@@ -0,0 +1,51 @@
+package workflow
+
+import (
+ "errors"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+)
+
+const (
+ stagePrepare = "prepare"
+ stageAuthorize = "authorize"
+ stageCheckout = "checkout"
+ stageAnalyze = "analyze"
+ stagePersist = "persist"
+ stageWorkflow = "workflow"
+)
+
+type executionError struct {
+ failure analysis.Failure
+ cause error
+}
+
+func (e *executionError) Error() string { return e.cause.Error() }
+func (e *executionError) Unwrap() error { return e.cause }
+
+func fail(stage, code, message string, retryable bool, cause error) error {
+ err := &executionError{
+ failure: analysis.Failure{Stage: stage, Code: code, Message: message, Retryable: retryable},
+ cause: cause,
+ }
+ if !retryable {
+ return workflowbiz.Permanent(err)
+ }
+
+ return err
+}
+
+func failureFrom(cause error) analysis.Failure {
+ var execution *executionError
+ if errors.As(cause, &execution) {
+ return execution.failure
+ }
+
+ return analysis.Failure{
+ Stage: stageWorkflow,
+ Code: "analysis.execution_failed",
+ Message: "Analysis failed",
+ Retryable: cause != nil && !workflowbiz.IsPermanent(cause),
+ }
+}
diff --git a/internal/analysis/workflow/runner.go b/internal/analysis/workflow/runner.go
new file mode 100644
index 0000000..a88d456
--- /dev/null
+++ b/internal/analysis/workflow/runner.go
@@ -0,0 +1,170 @@
+package workflow
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "runtime"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/pkg/analyzer"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type Authorizer interface {
+ Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error)
+}
+
+type Runner struct {
+ store analysis.Store
+ authorizer Authorizer
+ git gitrepo.Manager
+ analyzer analyzer.Analyzer
+ timeout time.Duration
+}
+
+func NewRunner(store analysis.Store, authorizer Authorizer, git gitrepo.Manager, engine analyzer.Analyzer, timeout time.Duration) *Runner {
+ return &Runner{store: store, authorizer: authorizer, git: git, analyzer: engine, timeout: timeout}
+}
+
+func (r *Runner) Execute(ctx context.Context, runID uuid.UUID) error {
+ work, err := r.store.LoadWork(ctx, runID)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+
+ return fail(stagePrepare, "analysis.run_load_failed", "Unable to load analysis run", true, err)
+ }
+ if work.Run.Status == "queued" {
+ if _, err = r.store.Start(ctx, runID); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+
+ return fail(stagePrepare, "analysis.run_start_failed", "Unable to start analysis run", true, err)
+ }
+ } else if work.Run.Status != "running" {
+ return nil
+ }
+ if _, err := r.authorizer.Membership(ctx, auth.Actor{UserID: work.Run.RequestedBy}, work.Run.WorkspaceID, "member"); err != nil {
+ return fail(stageAuthorize, "analysis.authorization_revoked", "Requesting user no longer has workspace access", false, err)
+ }
+ definition, timeout, err := frozenProfile(work.Run.ProfileSnapshot, r.timeout)
+ if err != nil {
+ return fail(stagePrepare, "analysis.profile_snapshot_invalid", "Analysis profile snapshot is invalid", false, err)
+ }
+ proceed, err := r.advance(ctx, runID, stageCheckout)
+ if err != nil || !proceed {
+ return err
+ }
+ analysisContext, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ directory, cleanup, err := r.git.Checkout(analysisContext, work.Run.RepositoryID, work.CommitSHA)
+ if err != nil {
+ if gitrepo.IsPermanent(err) {
+ return fail(stageCheckout, "analysis.snapshot_unavailable", "Commit snapshot is no longer available", false, err)
+ }
+
+ return fail(stageCheckout, "analysis.checkout_failed", "Unable to prepare commit snapshot", true, err)
+ }
+ defer func() { _ = cleanup() }()
+ proceed, err = r.advance(ctx, runID, stageAnalyze)
+ if err != nil || !proceed {
+ return err
+ }
+ result, err := r.analyzer.Analyze(analysisContext, analyzer.Request{
+ Directory: directory,
+ CommitSHA: work.CommitSHA,
+ Parameters: append(json.RawMessage(nil), work.Run.ProfileSnapshot...),
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, analyzer.ErrOutputTooLarge):
+ return fail(stageAnalyze, "analysis.output_too_large", "Analyzer output exceeded the configured limit", false, err)
+ case errors.Is(err, analyzer.ErrInvalidOutput):
+ return fail(stageAnalyze, "analysis.output_invalid", "Analyzer returned invalid output", false, err)
+ case errors.Is(err, context.DeadlineExceeded):
+ return fail(stageAnalyze, "analysis.timeout", "Analysis timed out", true, err)
+ default:
+ return fail(stageAnalyze, "analysis.analyzer_failed", "Analyzer execution failed", true, err)
+ }
+ }
+ if err := validateResult(work.Run, definition, result); err != nil {
+ return fail(stageAnalyze, "analysis.output_invalid", "Analyzer returned invalid output", false, err)
+ }
+ proceed, err = r.advance(ctx, runID, stagePersist)
+ if err != nil || !proceed {
+ return err
+ }
+ execution := analysis.Execution{
+ Environment: runtime.Version() + "/" + runtime.GOOS + "/" + runtime.GOARCH,
+ }
+ _, err = r.store.Finish(ctx, runID, result.Data, result.RawArtifact, execution)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return fail(stagePersist, "analysis.result_persist_failed", "Unable to save analysis result", true, err)
+ }
+
+ return nil
+}
+
+func frozenProfile(snapshot json.RawMessage, ceiling time.Duration) (analysis.ProfileDefinition, time.Duration, error) {
+ var definition analysis.ProfileDefinition
+ if err := json.Unmarshal(snapshot, &definition); err != nil || definition.TimeoutSeconds < 1 || definition.TimeoutSeconds > 86400 || definition.DimensionKey != "code_scale" || definition.Analyzer.Key != "scc" || !definition.Analyzer.Required {
+ return analysis.ProfileDefinition{}, 0, errors.New("invalid frozen profile")
+ }
+ timeout := time.Duration(definition.TimeoutSeconds) * time.Second
+ if timeout > ceiling {
+ return definition, ceiling, nil
+ }
+
+ return definition, timeout, nil
+}
+
+func profileTimeout(snapshot json.RawMessage, ceiling time.Duration) (time.Duration, error) {
+ _, timeout, err := frozenProfile(snapshot, ceiling)
+
+ return timeout, err
+}
+
+func validateResult(run analysis.Run, definition analysis.ProfileDefinition, result analyzer.Result) error {
+ if result.DimensionKey != run.DimensionKey || result.DimensionKey != definition.DimensionKey {
+ return fmt.Errorf("%w: dimension key does not match frozen run", analyzer.ErrInvalidOutput)
+ }
+ if result.AnalyzerKey != definition.Analyzer.Key {
+ return fmt.Errorf("%w: analyzer key does not match frozen profile", analyzer.ErrInvalidOutput)
+ }
+ if result.AnalyzerVersion == "" || result.AnalyzerVersion != run.AnalyzerVersion {
+ return fmt.Errorf("%w: analyzer version does not match frozen run", analyzer.ErrInvalidOutput)
+ }
+ if len(result.Data) == 0 || !json.Valid(result.Data) {
+ return fmt.Errorf("%w: result data is not valid JSON", analyzer.ErrInvalidOutput)
+ }
+ if len(result.RawArtifact) == 0 || !json.Valid(result.RawArtifact) {
+ return fmt.Errorf("%w: raw artifact is not valid JSON", analyzer.ErrInvalidOutput)
+ }
+
+ return nil
+}
+
+func (r *Runner) advance(ctx context.Context, runID uuid.UUID, stage string) (bool, error) {
+ ok, err := r.store.SetStage(ctx, runID, stage)
+ if err != nil {
+ return false, fail(stage, "analysis.stage_update_failed", "Unable to update analysis progress", true, err)
+ }
+
+ return ok, nil
+}
+
+func (r *Runner) MarkFailed(ctx context.Context, runID uuid.UUID, cause error) error {
+ return r.store.Fail(ctx, runID, failureFrom(cause))
+}
diff --git a/internal/analysis/workflow/runner_test.go b/internal/analysis/workflow/runner_test.go
new file mode 100644
index 0000000..fd32cd1
--- /dev/null
+++ b/internal/analysis/workflow/runner_test.go
@@ -0,0 +1,294 @@
+package workflow
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/pkg/analyzer"
+ "github.com/fuchencong/mooncode/pkg/analyzer/scc"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+)
+
+type analysisWorkflowStore struct {
+ analysis.Store
+ work analysis.WorkItem
+ started bool
+ result json.RawMessage
+ raw json.RawMessage
+ execution analysis.Execution
+ failed analysis.Failure
+ stages []string
+ stopAt string
+}
+
+func (s *analysisWorkflowStore) LoadWork(context.Context, uuid.UUID) (analysis.WorkItem, error) {
+ if len(s.work.Run.ProfileSnapshot) == 0 {
+ s.work.Run.ProfileSnapshot = analysis.DefaultProfileDefinition()
+ }
+ if s.work.Run.DimensionKey == "" {
+ s.work.Run.DimensionKey = "code_scale"
+ }
+ if s.work.Run.AnalyzerVersion == "" {
+ s.work.Run.AnalyzerVersion = scc.Version
+ }
+ return s.work, nil
+}
+
+func (s *analysisWorkflowStore) Start(context.Context, uuid.UUID) (analysis.Run, error) {
+ s.started = true
+ s.work.Run.Status = "running"
+
+ return s.work.Run, nil
+}
+
+func (s *analysisWorkflowStore) SetStage(_ context.Context, _ uuid.UUID, stage string) (bool, error) {
+ s.stages = append(s.stages, stage)
+ if stage == s.stopAt {
+ return false, nil
+ }
+ s.work.Run.Stage = stage
+
+ return true, nil
+}
+
+func (s *analysisWorkflowStore) Finish(_ context.Context, _ uuid.UUID, result, raw json.RawMessage, execution analysis.Execution) (analysis.Run, error) {
+ s.result = append(json.RawMessage(nil), result...)
+ s.raw = append(json.RawMessage(nil), raw...)
+ s.execution = execution
+
+ return s.work.Run, nil
+}
+
+func (s *analysisWorkflowStore) Fail(_ context.Context, _ uuid.UUID, failure analysis.Failure) error {
+ s.failed = failure
+
+ return nil
+}
+
+type analysisWorkflowAuthorizer struct {
+ actor auth.Actor
+ err error
+}
+
+func (a *analysisWorkflowAuthorizer) Membership(_ context.Context, actor auth.Actor, _ uuid.UUID, _ string) (identity.Membership, error) {
+ a.actor = actor
+
+ return identity.Membership{Role: "member"}, a.err
+}
+
+type analysisWorkflowGit struct {
+ repositoryID uuid.UUID
+ commitSHA string
+ directory string
+ cleanup bool
+ checkoutErr error
+}
+
+func (*analysisWorkflowGit) Provision(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) {
+ return gitrepo.Snapshot{}, nil
+}
+
+func (*analysisWorkflowGit) Sync(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) {
+ return gitrepo.Snapshot{}, nil
+}
+
+func (g *analysisWorkflowGit) Checkout(_ context.Context, repositoryID uuid.UUID, commitSHA string) (string, func() error, error) {
+ g.repositoryID = repositoryID
+ g.commitSHA = commitSHA
+ if g.checkoutErr != nil {
+ return "", nil, g.checkoutErr
+ }
+
+ return g.directory, func() error { g.cleanup = true; return nil }, nil
+}
+
+func (*analysisWorkflowGit) Purge(context.Context, uuid.UUID) error { return nil }
+func (*analysisWorkflowGit) ReleaseSnapshot(context.Context, uuid.UUID, uuid.UUID, string) error {
+ return nil
+}
+func (*analysisWorkflowGit) Path(uuid.UUID) string { return "" }
+
+type analysisWorkflowAnalyzer struct {
+ request analyzer.Request
+ result analyzer.Result
+ err error
+ deadline bool
+}
+
+func (a *analysisWorkflowAnalyzer) Analyze(ctx context.Context, request analyzer.Request) (analyzer.Result, error) {
+ a.request = request
+ _, a.deadline = ctx.Deadline()
+
+ return a.result, a.err
+}
+
+func TestRunnerAnalyzesPinnedCommitAndCleansCheckout(t *testing.T) {
+ runID, actorID, workspaceID, repositoryID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ store := &analysisWorkflowStore{work: analysis.WorkItem{
+ Run: analysis.Run{ID: runID, RequestedBy: actorID, WorkspaceID: workspaceID, RepositoryID: repositoryID, Status: "queued"},
+ CommitSHA: "1234567890abcdef",
+ }}
+ authorizer := &analysisWorkflowAuthorizer{}
+ git := &analysisWorkflowGit{directory: "/tmp/checkout"}
+ analyzer := &analysisWorkflowAnalyzer{result: analyzer.Result{
+ DimensionKey: "code_scale",
+ AnalyzerKey: "scc",
+ AnalyzerVersion: scc.Version,
+ Data: json.RawMessage(`{"summary":{"files":2,"code":42},"languages":[],"warnings":[]}`),
+ RawArtifact: json.RawMessage(`[{"Name":"Go"}]`),
+ }}
+ runner := NewRunner(store, authorizer, git, analyzer, time.Minute)
+
+ if err := runner.Execute(context.Background(), runID); err != nil {
+ t.Fatal(err)
+ }
+ if !store.started || authorizer.actor.UserID != actorID || git.repositoryID != repositoryID || git.commitSHA != store.work.CommitSHA {
+ t.Fatalf("unexpected workflow identity: store=%+v authorizer=%+v git=%+v", store, authorizer, git)
+ }
+ if analyzer.request.Directory != git.directory || analyzer.request.CommitSHA != store.work.CommitSHA || string(analyzer.request.Parameters) != string(store.work.Run.ProfileSnapshot) || !analyzer.deadline || !git.cleanup {
+ t.Fatalf("analysis checkout lifecycle was incomplete: analyzer=%+v git=%+v", analyzer, git)
+ }
+ var result scc.Result
+ if err := json.Unmarshal(store.result, &result); err != nil {
+ t.Fatal(err)
+ }
+ if result.Summary.Files != 2 || result.Summary.Code != 42 {
+ t.Fatalf("unexpected persisted result: %+v", result)
+ }
+ if string(store.raw) != `[{"Name":"Go"}]` {
+ t.Fatalf("raw analyzer artifact was not persisted: %s", store.raw)
+ }
+ if store.execution.Environment == "" {
+ t.Fatal("analysis execution environment was not persisted")
+ }
+ if got := strings.Join(store.stages, ","); got != "checkout,analyze,persist" {
+ t.Fatalf("analysis stages = %q", got)
+ }
+}
+
+func TestRunnerCleansCheckoutWhenAnalyzerFails(t *testing.T) {
+ store := &analysisWorkflowStore{work: analysis.WorkItem{
+ Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"},
+ CommitSHA: "abcdef",
+ }}
+ git := &analysisWorkflowGit{directory: "/tmp/checkout"}
+ analyzer := &analysisWorkflowAnalyzer{err: errors.New("scc failed")}
+ runner := NewRunner(store, &analysisWorkflowAuthorizer{}, git, analyzer, time.Minute)
+
+ err := runner.Execute(context.Background(), store.work.Run.ID)
+ if err == nil {
+ t.Fatal("expected analyzer failure")
+ }
+ if !git.cleanup || store.result != nil {
+ t.Fatalf("failed analysis did not clean checkout or persisted a result: git=%+v result=%s", git, store.result)
+ }
+ if err := runner.MarkFailed(context.Background(), store.work.Run.ID, err); err != nil {
+ t.Fatal(err)
+ }
+ if store.failed.Stage != stageAnalyze || store.failed.Code != "analysis.analyzer_failed" || !store.failed.Retryable || store.failed.Message != "Analyzer execution failed" {
+ t.Fatalf("unexpected projected failure: %+v", store.failed)
+ }
+}
+
+func TestRunnerMarksInvalidAnalyzerOutputAsNonRetryable(t *testing.T) {
+ store := &analysisWorkflowStore{work: analysis.WorkItem{
+ Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"},
+ CommitSHA: "abcdef",
+ }}
+ runner := NewRunner(
+ store,
+ &analysisWorkflowAuthorizer{},
+ &analysisWorkflowGit{directory: "/tmp/checkout"},
+ &analysisWorkflowAnalyzer{err: analyzer.ErrInvalidOutput},
+ time.Minute,
+ )
+
+ err := runner.Execute(context.Background(), store.work.Run.ID)
+ if err == nil {
+ t.Fatal("expected invalid analyzer output failure")
+ }
+ if err := runner.MarkFailed(context.Background(), store.work.Run.ID, err); err != nil {
+ t.Fatal(err)
+ }
+ if store.failed.Stage != stageAnalyze || store.failed.Code != "analysis.output_invalid" || store.failed.Retryable {
+ t.Fatalf("unexpected projected failure: %+v", store.failed)
+ }
+}
+
+func TestRunnerSkipsTerminalRun(t *testing.T) {
+ store := &analysisWorkflowStore{work: analysis.WorkItem{Run: analysis.Run{ID: uuid.New(), Status: "succeeded"}}}
+ git := &analysisWorkflowGit{}
+ analyzer := &analysisWorkflowAnalyzer{}
+ runner := NewRunner(store, &analysisWorkflowAuthorizer{}, git, analyzer, time.Minute)
+
+ if err := runner.Execute(context.Background(), store.work.Run.ID); err != nil {
+ t.Fatal(err)
+ }
+ if store.started || analyzer.request.Directory != "" || git.repositoryID != uuid.Nil {
+ t.Fatal("terminal analysis was executed again")
+ }
+}
+
+func TestRunnerRejectsResultMetadataThatDoesNotMatchFrozenRun(t *testing.T) {
+ store := &analysisWorkflowStore{work: analysis.WorkItem{
+ Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"},
+ CommitSHA: "abcdef",
+ }}
+ engine := &analysisWorkflowAnalyzer{result: analyzer.Result{
+ DimensionKey: "code_scale",
+ AnalyzerKey: "scc",
+ AnalyzerVersion: "unexpected-version",
+ Data: json.RawMessage(`{}`),
+ RawArtifact: json.RawMessage(`[]`),
+ }}
+ runner := NewRunner(store, &analysisWorkflowAuthorizer{}, &analysisWorkflowGit{directory: "/tmp/checkout"}, engine, time.Minute)
+
+ err := runner.Execute(context.Background(), store.work.Run.ID)
+ if err == nil {
+ t.Fatal("expected mismatched analyzer result to be rejected")
+ }
+ if err := runner.MarkFailed(context.Background(), store.work.Run.ID, err); err != nil {
+ t.Fatal(err)
+ }
+ if store.failed.Code != "analysis.output_invalid" || store.failed.Retryable {
+ t.Fatalf("unexpected projected failure: %+v", store.failed)
+ }
+}
+
+func TestRunnerStopsWhenCancellationWinsStageTransition(t *testing.T) {
+ store := &analysisWorkflowStore{
+ work: analysis.WorkItem{Run: analysis.Run{ID: uuid.New(), RequestedBy: uuid.New(), WorkspaceID: uuid.New(), RepositoryID: uuid.New(), Status: "running"}},
+ stopAt: stageCheckout,
+ }
+ git := &analysisWorkflowGit{}
+ runner := NewRunner(store, &analysisWorkflowAuthorizer{}, git, &analysisWorkflowAnalyzer{}, time.Minute)
+
+ if err := runner.Execute(context.Background(), store.work.Run.ID); err != nil {
+ t.Fatal(err)
+ }
+ if git.repositoryID != uuid.Nil {
+ t.Fatal("checkout ran after cancellation won the stage transition")
+ }
+}
+
+func TestProfileTimeoutUsesSnapshotWithConfiguredCeiling(t *testing.T) {
+ timeout, err := profileTimeout(json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":30,"analyzer":{"key":"scc","required":true}}`), time.Minute)
+ if err != nil || timeout != 30*time.Second {
+ t.Fatalf("profileTimeout() = (%v, %v), want (30s, nil)", timeout, err)
+ }
+ timeout, err = profileTimeout(json.RawMessage(`{"dimensionKey":"code_scale","timeoutSeconds":120,"analyzer":{"key":"scc","required":true}}`), time.Minute)
+ if err != nil || timeout != time.Minute {
+ t.Fatalf("profileTimeout() ceiling = (%v, %v), want (1m, nil)", timeout, err)
+ }
+ if _, err := profileTimeout(json.RawMessage(`{"timeoutSeconds":0}`), time.Minute); err == nil {
+ t.Fatal("expected invalid profile timeout")
+ }
+}
diff --git a/internal/bootstrap/app.go b/internal/bootstrap/app.go
deleted file mode 100644
index 2974d02..0000000
--- a/internal/bootstrap/app.go
+++ /dev/null
@@ -1,364 +0,0 @@
-package bootstrap
-
-import (
- "context"
- "encoding/base64"
- "errors"
- "fmt"
- "net"
- "net/http"
- "os"
- "os/signal"
- "runtime/debug"
- "sync"
- "syscall"
- "time"
-
- channelmanager "github.com/mooncode-ai/mooncode/internal/channel"
- "github.com/mooncode-ai/mooncode/internal/checkout"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/controller"
- "github.com/mooncode-ai/mooncode/internal/health"
- "github.com/mooncode-ai/mooncode/internal/jobs"
- "github.com/mooncode-ai/mooncode/internal/observability"
- "github.com/mooncode-ai/mooncode/internal/outbox"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/repository/postgres"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
- "github.com/mooncode-ai/mooncode/internal/service"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
- "github.com/mooncode-ai/mooncode/pkg/channel/dingtalk"
- "github.com/mooncode-ai/mooncode/pkg/channel/fake"
- "github.com/mooncode-ai/mooncode/pkg/channel/feishu"
- gitcore "github.com/mooncode-ai/mooncode/pkg/git"
- "github.com/mooncode-ai/mooncode/pkg/scm"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
- "go.uber.org/dig"
- "golang.org/x/sync/errgroup"
-)
-
-type BuildInfo struct {
- Version string
- Commit string
- Date string
-}
-
-type App struct {
- cfg config.Config
- application *http.Server
- admin *http.Server
- health *health.State
- logger zerolog.Logger
- providers observability.Shutdowner
- database databaseResource
- jobs backgroundResource
- outbox backgroundResource
- channels backgroundResource
- shutdown sync.Once
- shutdownErr error
-}
-
-type databaseResource interface {
- Check(context.Context) error
- Close()
-}
-
-type backgroundResource interface {
- Run(context.Context) error
-}
-
-func New(cfg config.Config, build BuildInfo) (*App, error) {
- container := dig.New()
- providers := []any{
- func() config.Config { return cfg },
- func() observability.ServiceInfo {
- return observability.ServiceInfo{
- Version: build.Version,
- Commit: build.Commit,
- Environment: cfg.Service.Environment,
- InstanceID: serviceInstanceID(),
- }
- },
- observability.NewDependencies,
- postgres.NewStore,
- func(store *postgres.Store) repository.IdentityStore { return store },
- func(store *postgres.Store) repository.MetadataStore { return store },
- func(store *postgres.Store) repository.OutboxStore { return store },
- func(store *postgres.Store) repository.ChannelStore { return store },
- func(store *postgres.Store) repository.MutationStore { return store },
- func(store *postgres.Store) secretstore.RecordStore { return store },
- newSecretStore,
- newCheckoutStore,
- newSCMRegistry,
- newChannelRegistry,
- newGitClient,
- service.NewIdentityService,
- service.NewRegistrationService,
- service.NewWorkspaceService,
- service.NewAuditService,
- service.NewSCMConnectionService,
- service.NewRepositoryService,
- service.NewChannelService,
- service.NewMessageService,
- service.NewOverviewService,
- controller.NewRepositoryController,
- controller.NewWorkspaceController,
- controller.NewAuditController,
- controller.NewChannelController,
- controller.NewOverviewController,
- jobs.NewGitSyncHandler,
- newJobRunner,
- outbox.NewLocalPublisher,
- newOutboxDispatcher,
- newChannelManager,
- controller.NewIdentityController,
- controller.NewRegistrationController,
- health.NewState,
- newApplicationServer,
- newAdminServer,
- newApp,
- }
- for _, provider := range providers {
- if err := container.Provide(provider); err != nil {
- return nil, fmt.Errorf("provide dependency: %w", err)
- }
- }
-
- var app *App
- if err := container.Invoke(func(resolved *App) { app = resolved }); err != nil {
- return nil, fmt.Errorf("build application: %w", err)
- }
- return app, nil
-}
-
-func newChannelRegistry(cfg config.Config) (*channelcore.Registry, error) {
- options := []channelcore.RegistryOption{
- channelcore.WithFactory(feishu.NewFactory()),
- channelcore.WithFactory(dingtalk.NewFactory()),
- }
- if cfg.Channels.FakeEnabled {
- options = append(options, channelcore.WithFactory(fake.NewFactory()))
- }
- return channelcore.NewRegistry(options...)
-}
-
-func newChannelManager(store repository.ChannelStore, secrets secretstore.SecretStore, registry *channelcore.Registry, info observability.ServiceInfo, cfg config.Config, logger zerolog.Logger, metrics *observability.Metrics, provider trace.TracerProvider) *channelmanager.Manager {
- return channelmanager.NewManager(store, secrets, registry, info.InstanceID, cfg, logger, channelmanager.WithObserver(metrics), channelmanager.WithTracerProvider(provider))
-}
-
-func newSCMRegistry(cfg config.Config) (*scm.Registry, error) {
- policyOptions := make([]scm.PolicyOption, 0, len(cfg.Git.AllowedPrivateCIDRs))
- for _, cidr := range cfg.Git.AllowedPrivateCIDRs {
- _, network, err := net.ParseCIDR(cidr)
- if err != nil {
- return nil, fmt.Errorf("parse allowed Git network %q: %w", cidr, err)
- }
- policyOptions = append(policyOptions, scm.WithAllowedNetwork(network))
- }
- policy := scm.NewURLPolicy(policyOptions...)
- return scm.NewRegistry(
- scm.WithProvider(scm.NewHTTPSProvider("github", policy, "github.com")),
- scm.WithProvider(scm.NewHTTPSProvider("gitlab", policy)),
- scm.WithProvider(scm.NewHTTPSProvider("generic", policy)),
- )
-}
-
-func newJobRunner(store *postgres.Store, handler *jobs.GitSyncHandler, info observability.ServiceInfo, cfg config.Config, logger zerolog.Logger, metrics *observability.Metrics, provider trace.TracerProvider, propagator propagation.TextMapPropagator) (*jobs.Runner, error) {
- return jobs.NewRunner(store, info.InstanceID, cfg, logger, jobs.WithHandler(handler), jobs.WithObserver(metrics), jobs.WithTracing(provider, propagator))
-}
-
-func newOutboxDispatcher(store repository.OutboxStore, publisher outbox.Publisher, info observability.ServiceInfo, cfg config.Config, logger zerolog.Logger, metrics *observability.Metrics, provider trace.TracerProvider, propagator propagation.TextMapPropagator) (*outbox.Dispatcher, error) {
- return outbox.New(store, publisher, info.InstanceID, cfg, logger, metrics, provider, propagator)
-}
-
-func newCheckoutStore(cfg config.Config) (checkout.Store, error) {
- return checkout.NewFilesystemStore(cfg.Git.RepositoryDirectory)
-}
-
-func newGitClient(cfg config.Config, metrics *observability.Metrics, provider trace.TracerProvider) gitcore.Client {
- client := gitcore.NewClient(gitcore.WithDefaultTimeout(cfg.Git.CloneTimeout))
- return gitcore.Wrap(client, observability.InstrumentGit(metrics, provider))
-}
-
-func newSecretStore(cfg config.Config, records secretstore.RecordStore) (secretstore.SecretStore, error) {
- key, err := base64.StdEncoding.DecodeString(cfg.SecretStore.MasterKey)
- if err != nil {
- return nil, fmt.Errorf("decode secret store master key: %w", err)
- }
- return secretstore.NewEncryptedStore(records,
- secretstore.WithMasterKey(cfg.SecretStore.KeyVersion, key),
- secretstore.WithCurrentKeyVersion(cfg.SecretStore.KeyVersion),
- )
-}
-
-func newApp(
- cfg config.Config,
- application applicationServer,
- admin adminServer,
- state *health.State,
- logger zerolog.Logger,
- providers observability.Shutdowner,
- database *postgres.Store,
- jobRunner *jobs.Runner,
- outboxDispatcher *outbox.Dispatcher,
- channelManager *channelmanager.Manager,
-) *App {
- return &App{
- cfg: cfg,
- application: application.server,
- admin: admin.server,
- health: state,
- logger: logger,
- providers: providers,
- database: database,
- jobs: jobRunner,
- outbox: outboxDispatcher,
- channels: channelManager,
- }
-}
-
-func (app *App) Run(parent context.Context) error {
- ctx, stopSignals := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM)
- defer stopSignals()
-
- applicationListener, err := net.Listen("tcp", app.application.Addr)
- if err != nil {
- listenErr := fmt.Errorf("listen on application address %s: %w", app.application.Addr, err)
- return errors.Join(listenErr, app.Shutdown(context.Background()))
- }
- adminListener, err := net.Listen("tcp", app.admin.Addr)
- if err != nil {
- _ = applicationListener.Close()
- listenErr := fmt.Errorf("listen on admin address %s: %w", app.admin.Addr, err)
- return errors.Join(listenErr, app.Shutdown(context.Background()))
- }
-
- group, groupCtx := errgroup.WithContext(ctx)
- fatal := make(chan error, 2)
- serve := func(name string, server *http.Server, listener net.Listener) func() error {
- return func() error {
- err := runCritical(name, func() error { return server.Serve(listener) })
- if errors.Is(err, http.ErrServerClosed) {
- return nil
- }
- if err != nil {
- select {
- case fatal <- err:
- default:
- }
- }
- return err
- }
- }
- group.Go(serve("application HTTP server", app.application, applicationListener))
- group.Go(serve("admin HTTP server", app.admin, adminListener))
- group.Go(func() error { return app.monitorDatabase(groupCtx) })
- if app.jobs != nil {
- group.Go(func() error {
- return runCritical("job runner", func() error { return app.jobs.Run(groupCtx) })
- })
- }
- if app.outbox != nil {
- group.Go(func() error {
- return runCritical("outbox dispatcher", func() error { return app.outbox.Run(groupCtx) })
- })
- }
- if app.channels != nil {
- group.Go(func() error {
- return runCritical("channel manager", func() error { return app.channels.Run(groupCtx) })
- })
- }
-
- app.health.SetComponent("database", true)
- app.health.SetComponent("jobs", app.jobs != nil)
- app.health.SetComponent("outbox", app.outbox != nil)
- app.health.SetComponent("channels", app.channels != nil)
- app.health.SetReady(true)
- app.logger.Info().
- Str("application_address", applicationListener.Addr().String()).
- Str("admin_address", adminListener.Addr().String()).
- Msg("mooncode backend started")
-
- var runErr error
- select {
- case <-ctx.Done():
- case <-groupCtx.Done():
- case runErr = <-fatal:
- stopSignals()
- }
-
- shutdownErr := app.Shutdown(context.Background())
- waitErr := group.Wait()
- if runErr == nil && waitErr != nil && !errors.Is(waitErr, http.ErrServerClosed) {
- runErr = waitErr
- }
- return errors.Join(runErr, shutdownErr)
-}
-
-func (app *App) Shutdown(parent context.Context) error {
- app.shutdown.Do(func() {
- app.health.StartShutdown()
- app.logger.Info().Msg("shutting down mooncode backend")
- applicationErr := runShutdownPhase(parent, app.cfg.Shutdown.Timeout, app.application.Shutdown)
- adminErr := runShutdownPhase(parent, app.cfg.Shutdown.Timeout, app.admin.Shutdown)
- providerErr := runShutdownPhase(parent, app.cfg.Shutdown.Timeout, app.providers.Shutdown)
- app.database.Close()
- app.shutdownErr = errors.Join(
- ignoreServerClosed(applicationErr),
- ignoreServerClosed(adminErr),
- providerErr,
- )
- })
- return app.shutdownErr
-}
-
-func (app *App) monitorDatabase(ctx context.Context) error {
- ticker := time.NewTicker(app.cfg.Database.HealthInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return nil
- case <-ticker.C:
- checkCtx, cancel := context.WithTimeout(ctx, app.cfg.Database.ConnectTimeout)
- err := app.database.Check(checkCtx)
- cancel()
- app.health.SetComponent("database", err == nil)
- if err != nil {
- app.logger.Warn().Err(err).Str("operation", "database.health").Msg("PostgreSQL health check failed")
- }
- }
- }
-}
-
-func runShutdownPhase(parent context.Context, timeout time.Duration, function func(context.Context) error) error {
- ctx, cancel := context.WithTimeout(parent, timeout)
- defer cancel()
- return function(ctx)
-}
-
-func runCritical(name string, function func() error) (err error) {
- defer func() {
- if recovered := recover(); recovered != nil {
- err = fmt.Errorf("%s panic (%T)\n%s", name, recovered, debug.Stack())
- }
- }()
- return function()
-}
-
-func ignoreServerClosed(err error) error {
- if errors.Is(err, http.ErrServerClosed) {
- return nil
- }
- return err
-}
-
-func serviceInstanceID() string {
- hostname, err := os.Hostname()
- if err != nil || hostname == "" {
- hostname = "unknown"
- }
- return fmt.Sprintf("%s-%d", hostname, os.Getpid())
-}
diff --git a/internal/bootstrap/http.go b/internal/bootstrap/http.go
deleted file mode 100644
index 2f8a6bc..0000000
--- a/internal/bootstrap/http.go
+++ /dev/null
@@ -1,136 +0,0 @@
-package bootstrap
-
-import (
- "encoding/base64"
- "encoding/json"
- "fmt"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/gorilla/csrf"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/controller"
- "github.com/mooncode-ai/mooncode/internal/health"
- "github.com/mooncode-ai/mooncode/internal/middleware"
- "github.com/mooncode-ai/mooncode/internal/observability"
- "github.com/mooncode-ai/mooncode/internal/service"
- "github.com/prometheus/client_golang/prometheus"
- "github.com/prometheus/client_golang/prometheus/promhttp"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
-)
-
-type applicationServer struct {
- server *http.Server
-}
-
-type adminServer struct {
- server *http.Server
-}
-
-func newApplicationServer(
- cfg config.Config,
- logger zerolog.Logger,
- metrics *observability.Metrics,
- tracerProvider trace.TracerProvider,
- propagator propagation.TextMapPropagator,
- identities *service.IdentityService,
- identityController *controller.IdentityController,
- registrationController *controller.RegistrationController,
- workspaceController *controller.WorkspaceController,
- auditController *controller.AuditController,
- repositoryController *controller.RepositoryController,
- channelController *controller.ChannelController,
- overviewController *controller.OverviewController,
-) (applicationServer, error) {
- engine := gin.New()
- engine.Use(
- middleware.RequestID(),
- observability.TraceMiddleware(tracerProvider, propagator),
- observability.AccessLogMiddleware(logger),
- observability.MetricsMiddleware(metrics),
- observability.RecoveryMiddleware(logger),
- )
- api := engine.Group("/api/v1")
- limiter := middleware.NewRateLimiter(cfg.Security.RateLimit, metrics)
- api.Use(middleware.JSONBodyGuard(64<<10), middleware.GatewayIdentity(cfg.Identity, identities, logger), limiter.Middleware())
- api.GET("/csrf", func(c *gin.Context) {
- c.Header("Cache-Control", "no-store")
- c.JSON(http.StatusOK, gin.H{"token": csrf.Token(c.Request)})
- })
- identityController.Register(api)
- registrationController.Register(api)
- if workspaceController != nil {
- workspaceController.Register(api)
- }
- if auditController != nil {
- auditController.Register(api)
- }
- if repositoryController != nil {
- repositoryController.Register(api)
- }
- if channelController != nil {
- channelController.Register(api)
- }
- if overviewController != nil {
- overviewController.Register(api)
- }
- engine.NoRoute(func(c *gin.Context) {
- c.JSON(http.StatusNotFound, gin.H{
- "error": gin.H{
- "code": "http.not_found",
- "message": "Route not found",
- "requestId": middleware.RequestIDFromContext(c.Request.Context()),
- },
- })
- })
-
- key, err := base64.StdEncoding.DecodeString(cfg.Security.CSRF.Key)
- if err != nil {
- return applicationServer{}, fmt.Errorf("decode CSRF key: %w", err)
- }
- csrfHandler := csrf.Protect(key,
- csrf.Secure(cfg.Security.CSRF.Secure), csrf.Path("/"), csrf.SameSite(csrf.SameSiteLaxMode),
- csrf.RequestHeader("X-CSRF-Token"), csrf.TrustedOrigins(cfg.Security.CSRF.TrustedOrigins),
- csrf.ErrorHandler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
- writer.Header().Set("Content-Type", "application/json")
- writer.WriteHeader(http.StatusForbidden)
- _ = json.NewEncoder(writer).Encode(map[string]any{"error": map[string]any{"code": "csrf.invalid", "message": "CSRF validation failed", "requestId": middleware.RequestIDFromContext(request.Context())}})
- })),
- )(engine)
- handler := middleware.RequestIDHTTP(csrfHandler)
- return applicationServer{server: &http.Server{
- Addr: cfg.Server.Address,
- Handler: handler,
- ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout,
- ReadTimeout: cfg.Server.ReadTimeout,
- WriteTimeout: cfg.Server.WriteTimeout,
- IdleTimeout: cfg.Server.IdleTimeout,
- }}, nil
-}
-
-func newAdminServer(
- cfg config.Config,
- state *health.State,
- registry *prometheus.Registry,
- logger zerolog.Logger,
-) adminServer {
- mux := http.NewServeMux()
- controller.NewHealthController(state).Register(mux)
- mux.Handle("GET /metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))
-
- var handler http.Handler = mux
- handler = observability.RecoveryHTTP(logger, handler)
- handler = observability.AccessLogHTTP(logger, handler)
- handler = middleware.RequestIDHTTP(handler)
-
- return adminServer{server: &http.Server{
- Addr: cfg.Admin.Address,
- Handler: handler,
- ReadHeaderTimeout: cfg.Admin.ReadHeaderTimeout,
- ReadTimeout: cfg.Admin.ReadTimeout,
- WriteTimeout: cfg.Admin.WriteTimeout,
- IdleTimeout: cfg.Admin.IdleTimeout,
- }}
-}
diff --git a/internal/bootstrap/http_test.go b/internal/bootstrap/http_test.go
deleted file mode 100644
index c8d664b..0000000
--- a/internal/bootstrap/http_test.go
+++ /dev/null
@@ -1,225 +0,0 @@
-package bootstrap
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/controller"
- "github.com/mooncode-ai/mooncode/internal/health"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/observability"
- "github.com/mooncode-ai/mooncode/internal/repository/memory"
- "github.com/mooncode-ai/mooncode/internal/service"
- "github.com/prometheus/client_golang/prometheus"
- "github.com/rs/zerolog"
- "github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace/noop"
-)
-
-func TestApplicationServerReturnsStructuredNotFound(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cfg, err := config.NewLoader().Load("", nil)
- require.NoError(t, err)
- cfg.Registration.Mode = "public"
- registry := prometheus.NewRegistry()
- store := memory.New()
- identities := service.NewIdentityService(store, cfg)
- registrations := service.NewRegistrationService(store, identities, cfg)
- server, err := newApplicationServer(
- cfg,
- zerolog.New(io.Discard),
- observability.NewMetrics(registry),
- noop.NewTracerProvider(),
- propagation.TraceContext{},
- identities,
- controller.NewIdentityController(identities),
- controller.NewRegistrationController(registrations, identities),
- nil,
- nil,
- nil,
- nil,
- nil,
- )
- require.NoError(t, err)
-
- response := httptest.NewRecorder()
- server.server.Handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/missing", nil))
- require.Equal(t, http.StatusNotFound, response.Code)
- require.Contains(t, response.Body.String(), `"code":"http.not_found"`)
- require.NotEmpty(t, response.Header().Get("X-Request-ID"))
-}
-
-func TestApplicationServerCSRFAndJSONProtection(t *testing.T) {
- server, cfg := newTestApplicationServer(t)
-
- csrfRequest := authenticatedRequest(cfg, http.MethodGet, "/api/v1/csrf", nil)
- csrfResponse := httptest.NewRecorder()
- server.server.Handler.ServeHTTP(csrfResponse, csrfRequest)
- require.Equal(t, http.StatusOK, csrfResponse.Code)
- var csrfBody struct {
- Token string `json:"token"`
- }
- require.NoError(t, json.Unmarshal(csrfResponse.Body.Bytes(), &csrfBody))
- require.NotEmpty(t, csrfBody.Token)
- require.NotEmpty(t, csrfResponse.Result().Cookies())
-
- tests := []struct {
- name string
- origin string
- contentType string
- token string
- cookie bool
- status int
- code string
- }{
- {name: "missing token", contentType: "application/json", cookie: true, status: http.StatusForbidden, code: "csrf.invalid"},
- {name: "untrusted origin", origin: "https://evil.example", contentType: "application/json", token: csrfBody.Token, cookie: true, status: http.StatusForbidden, code: "csrf.invalid"},
- {name: "valid token and origin", origin: "http://mooncode.localhost:3100", contentType: "application/json; charset=utf-8", token: csrfBody.Token, cookie: true, status: http.StatusNotFound, code: "http.not_found"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- request := authenticatedRequest(cfg, http.MethodPost, "/api/v1/missing", bytes.NewBufferString(`{}`))
- request.Header.Set("Content-Type", test.contentType)
- if test.origin != "" {
- request.Header.Set("Origin", test.origin)
- }
- if test.token != "" {
- request.Header.Set("X-CSRF-Token", test.token)
- }
- if test.cookie {
- for _, cookie := range csrfResponse.Result().Cookies() {
- request.AddCookie(cookie)
- }
- }
- response := httptest.NewRecorder()
- server.server.Handler.ServeHTTP(response, request)
- require.Equal(t, test.status, response.Code, response.Body.String())
- require.Contains(t, response.Body.String(), `"code":"`+test.code+`"`)
- require.NotEmpty(t, response.Header().Get("X-Request-ID"))
- })
- }
-}
-
-func newTestApplicationServer(t *testing.T) (applicationServer, config.Config) {
- t.Helper()
- gin.SetMode(gin.TestMode)
- cfg, err := config.NewLoader().Load("", nil)
- require.NoError(t, err)
- cfg.Registration.Mode = "public"
- registry := prometheus.NewRegistry()
- store := memory.New()
- identities := service.NewIdentityService(store, cfg)
- registrations := service.NewRegistrationService(store, identities, cfg)
- principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com"})
- require.NoError(t, err)
- _, err = registrations.Complete(t.Context(), principal, service.CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- server, err := newApplicationServer(
- cfg, zerolog.New(io.Discard), observability.NewMetrics(registry),
- noop.NewTracerProvider(), propagation.TraceContext{}, identities,
- controller.NewIdentityController(identities), controller.NewRegistrationController(registrations, identities), nil, nil, nil, nil, nil,
- )
- require.NoError(t, err)
- return server, cfg
-}
-
-func authenticatedRequest(cfg config.Config, method, path string, body io.Reader) *http.Request {
- request := httptest.NewRequest(method, path, body)
- request.Header.Set("X-MoonCode-Gateway-Token", cfg.Identity.GatewayToken)
- request.Header.Set("Remote-User", "moon")
- request.Header.Set("Remote-Sub", "42")
- return request
-}
-
-func TestAdminServerUsesPrivateRegistry(t *testing.T) {
- cfg, err := config.NewLoader().Load("", nil)
- require.NoError(t, err)
- state := health.NewState()
- state.SetReady(true)
- server := newAdminServer(cfg, state, prometheus.NewRegistry(), zerolog.New(io.Discard))
-
- for path, expected := range map[string]int{
- "/livez": http.StatusOK,
- "/readyz": http.StatusOK,
- "/metrics": http.StatusOK,
- } {
- t.Run(path, func(t *testing.T) {
- response := httptest.NewRecorder()
- server.server.Handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
- require.Equal(t, expected, response.Code)
- })
- }
-}
-
-func TestRunCriticalConvertsPanic(t *testing.T) {
- err := runCritical("test component", func() error {
- panic("boom")
- })
- require.ErrorContains(t, err, "test component panic (string)")
-}
-
-func TestAppRunStopsWithContext(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cfg, err := config.NewLoader().Load("", map[string]any{
- "server.address": "127.0.0.1:0",
- "admin.address": "127.0.0.1:0",
- })
- require.NoError(t, err)
- state := health.NewState()
- app := &App{
- cfg: cfg,
- application: &http.Server{Addr: cfg.Server.Address, Handler: http.NewServeMux()},
- admin: &http.Server{Addr: cfg.Admin.Address, Handler: http.NewServeMux()},
- health: state,
- logger: zerolog.New(io.Discard),
- providers: testShutdowner{},
- database: testDatabase{},
- }
-
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- require.NoError(t, app.Run(ctx))
-}
-
-func TestCoreBackgroundPanicFailsReadinessAndStopsProcess(t *testing.T) {
- cfg, err := config.NewLoader().Load("", map[string]any{
- "server.address": "127.0.0.1:0",
- "admin.address": "127.0.0.1:0",
- })
- require.NoError(t, err)
- state := health.NewState()
- app := &App{
- cfg: cfg,
- application: &http.Server{Addr: cfg.Server.Address, Handler: http.NewServeMux()},
- admin: &http.Server{Addr: cfg.Admin.Address, Handler: http.NewServeMux()},
- health: state,
- logger: zerolog.New(io.Discard),
- providers: testShutdowner{},
- database: testDatabase{},
- jobs: panicBackground{},
- }
- err = app.Run(t.Context())
- require.ErrorContains(t, err, "job runner panic (string)")
- require.False(t, state.Ready())
-}
-
-type testDatabase struct{}
-
-func (testDatabase) Check(context.Context) error { return nil }
-func (testDatabase) Close() {}
-
-type testShutdowner struct{}
-
-func (testShutdowner) Shutdown(context.Context) error { return nil }
-
-type panicBackground struct{}
-
-func (panicBackground) Run(context.Context) error { panic("boom") }
diff --git a/internal/channel/biz/config.go b/internal/channel/biz/config.go
new file mode 100644
index 0000000..eabb556
--- /dev/null
+++ b/internal/channel/biz/config.go
@@ -0,0 +1,76 @@
+package biz
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+)
+
+type configuration struct {
+ Values map[string]any `json:"values"`
+ SenderAllowList []string `json:"senderAllowList"`
+ GroupPolicy groupPolicy `json:"groupPolicy"`
+}
+
+type groupPolicy struct {
+ RequireMention bool `json:"requireMention"`
+ Prefix string `json:"prefix"`
+}
+
+func validateConfiguration(kind string, config json.RawMessage, credentials string) error {
+ if err := validateValues(kind, config); err != nil {
+ return err
+ }
+
+ var secrets map[string]string
+ if err := json.Unmarshal([]byte(credentials), &secrets); err != nil {
+ return fault.New(fault.Invalid, "channel.credentials_invalid", "Channel credentials must be a JSON object")
+ }
+ key := "app_secret"
+ if kind == "dingtalk" {
+ key = "client_secret"
+ }
+ if strings.TrimSpace(secrets[key]) == "" {
+ return fault.New(fault.Invalid, "channel.credential_required", fmt.Sprintf("Channel credential %q is required", key))
+ }
+
+ return nil
+}
+
+func validateValues(kind string, raw json.RawMessage) error {
+ var config configuration
+ if err := json.Unmarshal(raw, &config); err != nil || config.Values == nil {
+ return fault.New(fault.Invalid, "channel.values_invalid", "Channel values must be a JSON object")
+ }
+ required := []string{"app_id", "receive_id"}
+ if kind == "dingtalk" {
+ required = []string{"client_id", "robot_code", "open_conversation_id"}
+ }
+ for _, key := range required {
+ value, ok := config.Values[key].(string)
+ if !ok || strings.TrimSpace(value) == "" {
+ return fault.New(fault.Invalid, "channel.value_required", fmt.Sprintf("Channel value %q is required", key))
+ }
+ }
+ if len(config.SenderAllowList) == 0 {
+ return fault.New(fault.Invalid, "channel.allowlist_required", "Channel sender allowlist must contain at least one sender ID or an explicit wildcard")
+ }
+ seen := make(map[string]struct{}, len(config.SenderAllowList))
+ for _, sender := range config.SenderAllowList {
+ sender = strings.TrimSpace(sender)
+ if sender == "" {
+ return fault.New(fault.Invalid, "channel.allowlist_invalid", "Channel sender allowlist cannot contain an empty sender ID")
+ }
+ seen[sender] = struct{}{}
+ }
+ if _, wildcard := seen["*"]; wildcard && len(seen) != 1 {
+ return fault.New(fault.Invalid, "channel.allowlist_invalid", "Channel sender allowlist wildcard cannot be combined with sender IDs")
+ }
+ if strings.ContainsAny(config.GroupPolicy.Prefix, "\r\n") {
+ return fault.New(fault.Invalid, "channel.group_prefix_invalid", "Channel group prefix must be a single line")
+ }
+
+ return nil
+}
diff --git a/internal/channel/biz/config_test.go b/internal/channel/biz/config_test.go
new file mode 100644
index 0000000..966e854
--- /dev/null
+++ b/internal/channel/biz/config_test.go
@@ -0,0 +1,32 @@
+package biz
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestValidateConfiguration(t *testing.T) {
+ tests := []struct {
+ name string
+ kind string
+ config string
+ credentials string
+ wantError bool
+ }{
+ {name: "Feishu", kind: "feishu", config: `{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`, credentials: `{"app_secret":"secret"}`},
+ {name: "DingTalk", kind: "dingtalk", config: `{"values":{"client_id":"app","robot_code":"robot","open_conversation_id":"conversation"},"senderAllowList":["dingtalk:user"]}`, credentials: `{"client_secret":"secret"}`},
+ {name: "missing target", kind: "feishu", config: `{"values":{"app_id":"app"},"senderAllowList":["*"]}`, credentials: `{"app_secret":"secret"}`, wantError: true},
+ {name: "missing secret", kind: "dingtalk", config: `{"values":{"client_id":"app","robot_code":"robot","open_conversation_id":"conversation"},"senderAllowList":["*"]}`, credentials: `{}`, wantError: true},
+ {name: "missing allowlist", kind: "feishu", config: `{"values":{"app_id":"app","receive_id":"chat"}}`, credentials: `{"app_secret":"secret"}`, wantError: true},
+ {name: "mixed wildcard", kind: "feishu", config: `{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*","feishu:user"]}`, credentials: `{"app_secret":"secret"}`, wantError: true},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ err := validateConfiguration(test.kind, json.RawMessage(test.config), test.credentials)
+ if (err != nil) != test.wantError {
+ t.Fatalf("validateConfiguration() error = %v, wantError %v", err, test.wantError)
+ }
+ })
+ }
+}
diff --git a/internal/channel/biz/model.go b/internal/channel/biz/model.go
new file mode 100644
index 0000000..1a65e2e
--- /dev/null
+++ b/internal/channel/biz/model.go
@@ -0,0 +1,60 @@
+package biz
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Channel struct {
+ ID uuid.UUID `json:"id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ RuntimeStatus string `json:"-"`
+ SecretConfigured bool `json:"secretConfigured"`
+ ConfigVersion int64 `json:"configVersion"`
+ Config json.RawMessage `json:"config"`
+ NotificationEvents []string `json:"notificationEvents"`
+ LastConnectedAt *time.Time `json:"-"`
+ LastErrorMessage string `json:"-"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+type Status struct {
+ State string `json:"state"`
+ LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"`
+ LastErrorMessage string `json:"lastErrorMessage,omitempty"`
+}
+type Conversation struct {
+ ID uuid.UUID `json:"id"`
+ ChannelInstanceID uuid.UUID `json:"channelInstanceId"`
+ ChannelName string `json:"channelName"`
+ ChannelType string `json:"channelType"`
+ ExternalID string `json:"externalId"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+type Message struct {
+ ID uuid.UUID `json:"id"`
+ ChannelInstanceID uuid.UUID `json:"channelInstanceId"`
+ ChannelName string `json:"channelName"`
+ ChannelType string `json:"channelType"`
+ ConversationID uuid.UUID `json:"conversationId"`
+ ConversationExternalID string `json:"conversationExternalId"`
+ SenderCanonicalID string `json:"senderCanonicalId"`
+ SenderDisplayName string `json:"senderDisplayName"`
+ ConversationType string `json:"conversationType"`
+ Content json.RawMessage `json:"content"`
+ OccurredAt time.Time `json:"occurredAt"`
+}
+type Overview struct {
+ RepositoryCount int64 `json:"repositoryCount"`
+ RepositoryWithoutCodeCount int64 `json:"repositoryWithoutCodeCount"`
+ RecentFailedSyncCount int64 `json:"recentFailedSyncCount"`
+ ActiveAnalysisCount int64 `json:"activeAnalysisCount"`
+ ActiveChannelCount int64 `json:"activeChannelCount"`
+ RecentMessages []Message `json:"recentMessages"`
+}
diff --git a/internal/channel/biz/service.go b/internal/channel/biz/service.go
new file mode 100644
index 0000000..3c5b599
--- /dev/null
+++ b/internal/channel/biz/service.go
@@ -0,0 +1,179 @@
+package biz
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/fuchencong/mooncode/internal/platform/secret"
+ "github.com/google/uuid"
+)
+
+type Store interface {
+ List(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Channel], error)
+ Create(context.Context, uuid.UUID, uuid.UUID, string, string, []byte, []byte, int, json.RawMessage, []string) (Channel, error)
+ Get(context.Context, uuid.UUID, uuid.UUID) (Channel, error)
+ Update(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, string, json.RawMessage, []string, int64) (Channel, error)
+ RotateCredential(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, []byte, []byte, int, int64) (Channel, error)
+ Enable(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, bool) (Channel, error)
+ Delete(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (bool, error)
+ Conversations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Conversation], error)
+ Messages(context.Context, uuid.UUID, uuid.NullUUID, uuid.NullUUID, pagination.Request) (pagination.Page[Message], error)
+ Overview(context.Context, uuid.UUID) (Overview, error)
+}
+type Authorizer interface {
+ Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error)
+}
+type Service struct {
+ store Store
+ authorizer Authorizer
+ cipher secret.Cipher
+}
+
+func NewService(store Store, authorizer Authorizer, cipher secret.Cipher) *Service {
+ return &Service{store: store, authorizer: authorizer, cipher: cipher}
+}
+func (s *Service) List(ctx context.Context, a auth.Actor, w uuid.UUID, page pagination.Request) (pagination.Page[Channel], error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil {
+ return pagination.Page[Channel]{}, err
+ }
+ return s.store.List(ctx, w, page)
+}
+func (s *Service) Create(ctx context.Context, a auth.Actor, w uuid.UUID, kind, name, credentials string, config json.RawMessage, notificationEvents []string) (Channel, error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil {
+ return Channel{}, err
+ }
+ if kind != "feishu" && kind != "dingtalk" {
+ return Channel{}, fault.New(fault.Invalid, "channel.type_invalid", "Channel type must be feishu or dingtalk")
+ }
+ if strings.TrimSpace(name) == "" {
+ return Channel{}, fault.New(fault.Invalid, "channel.name_required", "Channel name is required")
+ }
+ if err := validateConfiguration(kind, config, credentials); err != nil {
+ return Channel{}, err
+ }
+ notificationEvents, err := normalizeNotificationEvents(notificationEvents)
+ if err != nil {
+ return Channel{}, err
+ }
+ ciphertext, nonce, version, err := s.cipher.Encrypt([]byte(strings.TrimSpace(credentials)))
+ if err != nil {
+ return Channel{}, err
+ }
+ return s.store.Create(ctx, w, a.UserID, kind, name, ciphertext, nonce, version, config, notificationEvents)
+}
+func (s *Service) Update(ctx context.Context, a auth.Actor, w, id uuid.UUID, name string, config json.RawMessage, notificationEvents []string, configVersion int64) (Channel, error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil {
+ return Channel{}, err
+ }
+ item, err := s.store.Get(ctx, w, id)
+ if err != nil {
+ return Channel{}, err
+ }
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return Channel{}, fault.New(fault.Invalid, "channel.name_required", "Channel name is required")
+ }
+ if err := validateValues(item.Type, config); err != nil {
+ return Channel{}, err
+ }
+ notificationEvents, err = normalizeNotificationEvents(notificationEvents)
+ if err != nil {
+ return Channel{}, err
+ }
+ if configVersion <= 0 || configVersion != item.ConfigVersion {
+ return Channel{}, fault.New(fault.Conflict, "channel.configuration_stale", "Channel configuration has changed; reload and try again")
+ }
+
+ return s.store.Update(ctx, w, a.UserID, id, name, config, notificationEvents, configVersion)
+}
+func (s *Service) RotateCredential(ctx context.Context, a auth.Actor, w, id uuid.UUID, credentials string, configVersion int64) (Channel, error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil {
+ return Channel{}, err
+ }
+ item, err := s.store.Get(ctx, w, id)
+ if err != nil {
+ return Channel{}, err
+ }
+ if configVersion <= 0 || configVersion != item.ConfigVersion {
+ return Channel{}, fault.New(fault.Conflict, "channel.configuration_stale", "Channel configuration has changed; reload and try again")
+ }
+ credentials = strings.TrimSpace(credentials)
+ if err := validateConfiguration(item.Type, item.Config, credentials); err != nil {
+ return Channel{}, err
+ }
+ ciphertext, nonce, version, err := s.cipher.Encrypt([]byte(credentials))
+ if err != nil {
+ return Channel{}, err
+ }
+
+ return s.store.RotateCredential(ctx, w, a.UserID, id, ciphertext, nonce, version, configVersion)
+}
+func (s *Service) Enable(ctx context.Context, a auth.Actor, w, id uuid.UUID, enabled bool) (Channel, error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil {
+ return Channel{}, err
+ }
+ if enabled {
+ item, err := s.store.Get(ctx, w, id)
+ if err != nil {
+ return Channel{}, err
+ }
+ if !item.SecretConfigured {
+ return Channel{}, fault.New(fault.Conflict, "channel.credential_missing", "Channel credential is not configured")
+ }
+ if err := validateValues(item.Type, item.Config); err != nil {
+ return Channel{}, err
+ }
+ }
+
+ return s.store.Enable(ctx, w, a.UserID, id, enabled)
+}
+func (s *Service) Delete(ctx context.Context, a auth.Actor, w, id uuid.UUID) error {
+ if _, err := s.authorizer.Membership(ctx, a, w, "admin"); err != nil {
+ return err
+ }
+ ok, err := s.store.Delete(ctx, w, a.UserID, id)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fault.New(fault.NotFound, "channel.not_found", "Channel not found")
+ }
+ return nil
+}
+func (s *Service) Status(ctx context.Context, a auth.Actor, w, id uuid.UUID) (Status, error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil {
+ return Status{}, err
+ }
+ item, err := s.store.Get(ctx, w, id)
+ if err != nil {
+ return Status{}, err
+ }
+ state := item.RuntimeStatus
+ if state == "" {
+ state = "disabled"
+ }
+ return Status{State: state, LastConnectedAt: item.LastConnectedAt, LastErrorMessage: item.LastErrorMessage}, nil
+}
+func (s *Service) Conversations(ctx context.Context, a auth.Actor, w uuid.UUID, page pagination.Request) (pagination.Page[Conversation], error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil {
+ return pagination.Page[Conversation]{}, err
+ }
+ return s.store.Conversations(ctx, w, page)
+}
+func (s *Service) Messages(ctx context.Context, a auth.Actor, w uuid.UUID, channelID, conversationID uuid.NullUUID, page pagination.Request) (pagination.Page[Message], error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil {
+ return pagination.Page[Message]{}, err
+ }
+ return s.store.Messages(ctx, w, channelID, conversationID, page)
+}
+func (s *Service) Overview(ctx context.Context, a auth.Actor, w uuid.UUID) (Overview, error) {
+ if _, err := s.authorizer.Membership(ctx, a, w, "member"); err != nil {
+ return Overview{}, err
+ }
+ return s.store.Overview(ctx, w)
+}
diff --git a/internal/channel/biz/service_test.go b/internal/channel/biz/service_test.go
new file mode 100644
index 0000000..cebbe21
--- /dev/null
+++ b/internal/channel/biz/service_test.go
@@ -0,0 +1,108 @@
+package biz
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/google/uuid"
+)
+
+type channelAuthorizer struct{ requiredRole string }
+
+func (a *channelAuthorizer) Membership(_ context.Context, _ auth.Actor, _ uuid.UUID, requiredRole string) (identity.Membership, error) {
+ a.requiredRole = requiredRole
+
+ return identity.Membership{Role: "admin"}, nil
+}
+
+type channelStore struct {
+ Store
+ item Channel
+ rotated bool
+ ciphertext []byte
+ nonce []byte
+ keyVersion int
+ version int64
+}
+
+func (s *channelStore) Get(context.Context, uuid.UUID, uuid.UUID) (Channel, error) {
+ return s.item, nil
+}
+
+func (s *channelStore) RotateCredential(_ context.Context, _, _, _ uuid.UUID, ciphertext, nonce []byte, keyVersion int, version int64) (Channel, error) {
+ s.rotated = true
+ s.ciphertext = append([]byte(nil), ciphertext...)
+ s.nonce = append([]byte(nil), nonce...)
+ s.keyVersion = keyVersion
+ s.version = version
+ s.item.ConfigVersion++
+
+ return s.item, nil
+}
+
+type channelCipher struct{ plaintext []byte }
+
+func (c *channelCipher) Encrypt(plaintext []byte) ([]byte, []byte, int, error) {
+ c.plaintext = append([]byte(nil), plaintext...)
+
+ return []byte("encrypted"), []byte("nonce"), 4, nil
+}
+
+func (*channelCipher) Decrypt([]byte, []byte, int) ([]byte, error) { return nil, nil }
+
+func TestRotateCredentialUsesAdminAuthorizationAndOptimisticVersion(t *testing.T) {
+ workspaceID, channelID := uuid.New(), uuid.New()
+ store := &channelStore{item: Channel{
+ ID: channelID, Type: "feishu", ConfigVersion: 3,
+ Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`),
+ }}
+ authorizer := &channelAuthorizer{}
+ cipher := &channelCipher{}
+ service := NewService(store, authorizer, cipher)
+
+ updated, err := service.RotateCredential(
+ context.Background(), auth.Actor{UserID: uuid.New()}, workspaceID, channelID,
+ `{"app_secret":"replacement"}`, 3,
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ if authorizer.requiredRole != "admin" || !store.rotated || store.version != 3 || updated.ConfigVersion != 4 {
+ t.Fatalf("unexpected rotation: role=%q store=%+v updated=%+v", authorizer.requiredRole, store, updated)
+ }
+ if string(cipher.plaintext) != `{"app_secret":"replacement"}` || string(store.ciphertext) == string(cipher.plaintext) || store.keyVersion != 4 {
+ t.Fatalf("credential was not encrypted correctly: plaintext=%q ciphertext=%q version=%d", cipher.plaintext, store.ciphertext, store.keyVersion)
+ }
+}
+
+func TestRotateCredentialRejectsStaleVersionBeforeEncryption(t *testing.T) {
+ store := &channelStore{item: Channel{
+ ID: uuid.New(), Type: "dingtalk", ConfigVersion: 8,
+ Config: json.RawMessage(`{"values":{"client_id":"app","robot_code":"robot","open_conversation_id":"conversation"},"senderAllowList":["*"]}`),
+ }}
+ cipher := &channelCipher{}
+ service := NewService(store, &channelAuthorizer{}, cipher)
+
+ if _, err := service.RotateCredential(context.Background(), auth.Actor{}, uuid.New(), store.item.ID, `{"client_secret":"replacement"}`, 7); err == nil {
+ t.Fatal("expected stale credential version to be rejected")
+ }
+ if store.rotated || cipher.plaintext != nil {
+ t.Fatal("stale credential request performed encryption or persistence")
+ }
+}
+
+func TestNormalizeNotificationEventsValidatesAndCanonicalizes(t *testing.T) {
+ events, err := normalizeNotificationEvents([]string{EventAnalysisSucceeded, EventAnalysisStarted, EventAnalysisSucceeded})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(events) != 2 || events[0] != EventAnalysisStarted || events[1] != EventAnalysisSucceeded {
+ t.Fatalf("normalized events = %v", events)
+ }
+ if _, err := normalizeNotificationEvents([]string{"analysis.unknown"}); err == nil {
+ t.Fatal("expected unknown notification event to be rejected")
+ }
+}
diff --git a/internal/channel/biz/subscription.go b/internal/channel/biz/subscription.go
new file mode 100644
index 0000000..e07f145
--- /dev/null
+++ b/internal/channel/biz/subscription.go
@@ -0,0 +1,38 @@
+package biz
+
+import (
+ "sort"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+)
+
+const (
+ EventAnalysisStarted = "analysis.started"
+ EventAnalysisSucceeded = "analysis.succeeded"
+ EventAnalysisFailed = "analysis.failed"
+)
+
+var notificationEvents = map[string]struct{}{
+ EventAnalysisStarted: {},
+ EventAnalysisSucceeded: {},
+ EventAnalysisFailed: {},
+}
+
+func normalizeNotificationEvents(events []string) ([]string, error) {
+ normalized := make([]string, 0, len(events))
+ seen := make(map[string]struct{}, len(events))
+ for _, event := range events {
+ if _, ok := notificationEvents[event]; !ok {
+ return nil, fault.New(fault.Invalid, "channel.subscription_event_invalid", "Channel notification event is invalid")
+ }
+ if _, duplicate := seen[event]; duplicate {
+ continue
+ }
+
+ seen[event] = struct{}{}
+ normalized = append(normalized, event)
+ }
+ sort.Strings(normalized)
+
+ return normalized, nil
+}
diff --git a/internal/channel/command/model.go b/internal/channel/command/model.go
new file mode 100644
index 0000000..99a52d2
--- /dev/null
+++ b/internal/channel/command/model.go
@@ -0,0 +1,37 @@
+package command
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+var (
+ ErrNotLinked = errors.New("channel identity is not linked")
+ ErrNoBinding = errors.New("conversation is not bound")
+ ErrInvalidLink = errors.New("channel identity link is invalid or expired")
+ ErrChannelInactive = errors.New("channel is disabled or its configuration is stale")
+)
+
+type Link struct {
+ ID uuid.UUID
+ WorkspaceID uuid.UUID
+ ChannelID uuid.UUID
+ ChannelVersion int64
+ SenderCanonicalID string
+ TokenHash []byte
+ ExpiresAt time.Time
+}
+
+type Store interface {
+ CheckActiveChannel(context.Context, uuid.UUID, uuid.UUID, int64) error
+ ExternalUserID(context.Context, uuid.UUID, string) (uuid.UUID, error)
+ CreateLink(context.Context, Link) error
+ LinkByHash(context.Context, []byte) (Link, error)
+ AcceptLink(context.Context, Link, uuid.UUID) error
+ ConversationBinding(context.Context, uuid.UUID, uuid.UUID, int64, string) (uuid.UUID, error)
+ BindConversation(context.Context, uuid.UUID, uuid.UUID, int64, string, uuid.UUID, uuid.UUID) error
+ UnbindConversation(context.Context, uuid.UUID, uuid.UUID, int64, string) error
+}
diff --git a/internal/channel/command/options.go b/internal/channel/command/options.go
new file mode 100644
index 0000000..584d346
--- /dev/null
+++ b/internal/channel/command/options.go
@@ -0,0 +1,21 @@
+package command
+
+import "time"
+
+type Option func(*Service)
+
+func WithClock(now func() time.Time) Option {
+ return func(service *Service) {
+ if now != nil {
+ service.now = now
+ }
+ }
+}
+
+func WithRandom(random func([]byte) (int, error)) Option {
+ return func(service *Service) {
+ if random != nil {
+ service.random = random
+ }
+ }
+}
diff --git a/internal/channel/command/service.go b/internal/channel/command/service.go
new file mode 100644
index 0000000..5a2bb05
--- /dev/null
+++ b/internal/channel/command/service.go
@@ -0,0 +1,340 @@
+package command
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/url"
+ "sort"
+ "strings"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+)
+
+const linkLifetime = 10 * time.Minute
+
+type Authorizer interface {
+ Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error)
+}
+
+type Repositories interface {
+ Get(context.Context, auth.Actor, uuid.UUID, uuid.UUID) (repository.Repository, error)
+ List(context.Context, auth.Actor, uuid.UUID, pagination.Request) (pagination.Page[repository.Repository], error)
+ Snapshots(context.Context, auth.Actor, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[repository.Snapshot], error)
+}
+
+type Analyses interface {
+ Create(context.Context, auth.Actor, uuid.UUID, uuid.UUID, analysis.CreateInput) (analysis.Run, error)
+ Get(context.Context, auth.Actor, uuid.UUID, uuid.UUID) (analysis.Run, error)
+}
+
+type Service struct {
+ store Store
+ authorizer Authorizer
+ repositories Repositories
+ analyses Analyses
+ appURL string
+ now func() time.Time
+ random func([]byte) (int, error)
+}
+
+func NewService(store Store, authorizer Authorizer, repositories Repositories, analyses Analyses, appURL string, options ...Option) *Service {
+ service := &Service{
+ store: store, authorizer: authorizer, repositories: repositories, analyses: analyses,
+ appURL: appURL, now: time.Now, random: rand.Read,
+ }
+ for _, option := range options {
+ option(service)
+ }
+
+ return service
+}
+
+func (s *Service) Handle(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, message channelpkg.InboundMessage) (string, error) {
+ fields := strings.Fields(message.Text)
+ if len(fields) == 0 {
+ return "", nil
+ }
+ verb := strings.ToLower(fields[0])
+ if verb == "help" {
+ return helpText(), nil
+ }
+ if err := s.store.CheckActiveChannel(ctx, workspaceID, channelID, channelVersion); err != nil {
+ return "", err
+ }
+ if verb == "link" {
+ return s.createLink(ctx, channelID, workspaceID, channelVersion, message.SenderCanonicalID)
+ }
+
+ userID, err := s.store.ExternalUserID(ctx, channelID, message.SenderCanonicalID)
+ if errors.Is(err, ErrNotLinked) {
+ return "This account is not linked. Send `link` to create a secure web authorization link.", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ actor := auth.Actor{UserID: userID}
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return "Your linked MoonCode user no longer has access to this workspace.", nil
+ }
+
+ switch verb {
+ case "repos":
+ return s.listRepositories(ctx, actor, workspaceID)
+ case "bind":
+ return s.bind(ctx, actor, workspaceID, channelID, channelVersion, message.ConversationID, fields[1:])
+ case "unbind":
+ if err := s.store.UnbindConversation(ctx, workspaceID, channelID, channelVersion, message.ConversationID); err != nil {
+ return "", err
+ }
+ return "This conversation is no longer bound to a repository.", nil
+ case "analyze":
+ return s.analyze(ctx, actor, workspaceID, channelID, channelVersion, message.ConversationID, fields[1:])
+ case "status", "report":
+ return s.analysisStatus(ctx, actor, workspaceID, verb, fields[1:])
+ default:
+ return "Unknown command.\n\n" + helpText(), nil
+ }
+}
+
+func (s *Service) Accept(ctx context.Context, actor auth.Actor, token string) error {
+ raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(token))
+ if err != nil || len(raw) != 32 {
+ return fault.New(fault.Invalid, "channel.identity_link_invalid", "Channel identity link is invalid or expired")
+ }
+ hash := sha256.Sum256(raw)
+ link, err := s.store.LinkByHash(ctx, hash[:])
+ if errors.Is(err, ErrInvalidLink) {
+ return fault.New(fault.Invalid, "channel.identity_link_invalid", "Channel identity link is invalid or expired")
+ }
+ if err != nil {
+ return err
+ }
+ if _, err := s.authorizer.Membership(ctx, actor, link.WorkspaceID, "member"); err != nil {
+ return err
+ }
+
+ if err := s.store.AcceptLink(ctx, link, actor.UserID); err != nil {
+ if errors.Is(err, ErrInvalidLink) {
+ return fault.New(fault.Invalid, "channel.identity_link_invalid", "Channel identity link is invalid or expired")
+ }
+
+ return err
+ }
+
+ return nil
+}
+
+func (s *Service) createLink(ctx context.Context, channelID, workspaceID uuid.UUID, channelVersion int64, senderCanonicalID string) (string, error) {
+ raw := make([]byte, 32)
+ if _, err := s.random(raw); err != nil {
+ return "", fmt.Errorf("create channel identity link: %w", err)
+ }
+ hash := sha256.Sum256(raw)
+ link := Link{ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: channelVersion, SenderCanonicalID: senderCanonicalID, TokenHash: hash[:], ExpiresAt: s.now().Add(linkLifetime)}
+ if err := s.store.CreateLink(ctx, link); err != nil {
+ return "", err
+ }
+ location, err := url.Parse(s.appURL)
+ if err != nil {
+ return "", fmt.Errorf("parse public app URL: %w", err)
+ }
+ query := location.Query()
+ query.Set("channelLink", base64.RawURLEncoding.EncodeToString(raw))
+ location.RawQuery = query.Encode()
+
+ return "Open this link within 10 minutes to connect this chat identity to your MoonCode user:\n" + location.String(), nil
+}
+
+func (s *Service) listRepositories(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID) (string, error) {
+ page, err := s.repositories.List(ctx, actor, workspaceID, pagination.Request{Limit: 20})
+ if err != nil {
+ return "", err
+ }
+ if len(page.Items) == 0 {
+ return "No repositories are available in this workspace.", nil
+ }
+ names := make([]string, 0, len(page.Items)+1)
+ for _, item := range page.Items {
+ names = append(names, fmt.Sprintf("- %s (%s)", item.Name, item.ID))
+ }
+ sort.Strings(names)
+ if page.NextCursor != "" {
+ names = append(names, "- … more repositories are available in the web app")
+ }
+
+ return "Available repositories:\n" + strings.Join(names, "\n"), nil
+}
+
+func (s *Service) bind(ctx context.Context, actor auth.Actor, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string, arguments []string) (string, error) {
+ if len(arguments) == 0 {
+ return "Usage: bind ", nil
+ }
+ item, err := s.resolveRepository(ctx, actor, workspaceID, strings.Join(arguments, " "))
+ if err != nil {
+ return err.Error(), nil
+ }
+ if err := s.store.BindConversation(ctx, workspaceID, channelID, channelVersion, conversationExternalID, item.ID, actor.UserID); err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("This conversation is now bound to %s.", item.Name), nil
+}
+
+func (s *Service) analyze(ctx context.Context, actor auth.Actor, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string, arguments []string) (string, error) {
+ repositoryID, err := s.store.ConversationBinding(ctx, workspaceID, channelID, channelVersion, conversationExternalID)
+ if errors.Is(err, ErrNoBinding) {
+ return "Bind this conversation first with `bind `.", nil
+ }
+ if err != nil {
+ return "", err
+ }
+ selected, err := s.repositories.Get(ctx, actor, workspaceID, repositoryID)
+ if err != nil {
+ return "", err
+ }
+
+ snapshotID := uuid.Nil
+ if len(arguments) > 0 {
+ var resolveErr error
+ snapshotID, resolveErr = s.resolveSnapshot(ctx, actor, workspaceID, repositoryID, arguments[0])
+ if resolveErr != nil {
+ return resolveErr.Error(), nil
+ }
+ }
+ run, err := s.analyses.Create(ctx, actor, workspaceID, repositoryID, analysis.CreateInput{SnapshotID: snapshotID})
+ if err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Analysis queued for %s at %.12s.\nRun: %s", selected.Name, run.CommitSHA, run.ID), nil
+}
+
+func (s *Service) analysisStatus(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, verb string, arguments []string) (string, error) {
+ if len(arguments) != 1 {
+ return fmt.Sprintf("Usage: %s ", verb), nil
+ }
+ runID, err := uuid.Parse(arguments[0])
+ if err != nil {
+ return "Run ID is invalid.", nil
+ }
+ run, err := s.analyses.Get(ctx, actor, workspaceID, runID)
+ if err != nil {
+ return "", err
+ }
+ if verb == "status" || run.Report == nil {
+ message := fmt.Sprintf("Run %s is %s at %.12s.", run.ID, run.Status, run.CommitSHA)
+ if run.ErrorMessage != "" {
+ message += "\nError: " + run.ErrorMessage
+ }
+ return message, nil
+ }
+ var result struct {
+ Summary struct {
+ Files int64 `json:"files"`
+ Code int64 `json:"code"`
+ } `json:"summary"`
+ Languages []json.RawMessage `json:"languages"`
+ }
+ if err := json.Unmarshal(run.Report.Result, &result); err != nil {
+ return "", err
+ }
+
+ return fmt.Sprintf("Report for %.12s: %d files, %d code lines, %d languages.\nRun: %s", run.CommitSHA, result.Summary.Files, result.Summary.Code, len(result.Languages), run.ID), nil
+}
+
+func (s *Service) resolveRepository(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, reference string) (repository.Repository, error) {
+ reference = strings.TrimSpace(reference)
+ if parsedID, err := uuid.Parse(reference); err == nil {
+ item, getErr := s.repositories.Get(ctx, actor, workspaceID, parsedID)
+ if getErr != nil {
+ return repository.Repository{}, errors.New("repository was not found")
+ }
+
+ return item, nil
+ }
+
+ var matched repository.Repository
+ cursor := ""
+ for {
+ page, err := s.repositories.List(ctx, actor, workspaceID, pageWithCursor(cursor, 100))
+ if err != nil {
+ return repository.Repository{}, err
+ }
+ for _, item := range page.Items {
+ if !strings.EqualFold(item.Name, reference) {
+ continue
+ }
+ if matched.ID != uuid.Nil {
+ return repository.Repository{}, errors.New("repository name is ambiguous; use its ID")
+ }
+ matched = item
+ }
+ if page.NextCursor == "" {
+ break
+ }
+ cursor = page.NextCursor
+ }
+ if matched.ID == uuid.Nil {
+ return repository.Repository{}, errors.New("repository was not found")
+ }
+
+ return matched, nil
+}
+
+func (s *Service) resolveSnapshot(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, prefix string) (uuid.UUID, error) {
+ prefix = strings.ToLower(strings.TrimSpace(prefix))
+ matched := uuid.Nil
+ cursor := ""
+ for {
+ page, err := s.repositories.Snapshots(ctx, actor, workspaceID, repositoryID, pageWithCursor(cursor, 100))
+ if err != nil {
+ return uuid.Nil, err
+ }
+ for _, snapshot := range page.Items {
+ if snapshot.SourceState != "available" || !strings.HasPrefix(strings.ToLower(snapshot.CommitSHA), prefix) {
+ continue
+ }
+ if matched != uuid.Nil {
+ return uuid.Nil, errors.New("commit prefix is ambiguous; provide more characters")
+ }
+ matched = snapshot.ID
+ }
+ if page.NextCursor == "" {
+ break
+ }
+ cursor = page.NextCursor
+ }
+ if matched == uuid.Nil {
+ return uuid.Nil, errors.New("no available local snapshot matches that commit prefix")
+ }
+
+ return matched, nil
+}
+
+func pageWithCursor(cursor string, limit int32) pagination.Request {
+ request := pagination.Request{Limit: limit}
+ if cursor == "" {
+ return request
+ }
+ decoded, _ := pagination.Decode(cursor)
+ request.After = &decoded
+
+ return request
+}
+
+func helpText() string {
+ return "MoonCode commands:\n- link\n- repos\n- bind \n- analyze [commit-prefix]\n- status \n- report \n- unbind\n- help"
+}
diff --git a/internal/channel/command/service_test.go b/internal/channel/command/service_test.go
new file mode 100644
index 0000000..048cd4d
--- /dev/null
+++ b/internal/channel/command/service_test.go
@@ -0,0 +1,206 @@
+package command
+
+import (
+ "context"
+ "crypto/sha256"
+ "errors"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+)
+
+type commandStore struct {
+ link Link
+ externalUserID uuid.UUID
+ boundRepositoryID uuid.UUID
+ acceptedUserID uuid.UUID
+ activeError error
+ externalError error
+}
+
+func (s *commandStore) CheckActiveChannel(context.Context, uuid.UUID, uuid.UUID, int64) error {
+ return s.activeError
+}
+func (s *commandStore) ExternalUserID(context.Context, uuid.UUID, string) (uuid.UUID, error) {
+ if s.externalError != nil {
+ return uuid.Nil, s.externalError
+ }
+ if s.externalUserID == uuid.Nil {
+ return uuid.Nil, ErrNotLinked
+ }
+
+ return s.externalUserID, nil
+}
+func (s *commandStore) CreateLink(_ context.Context, link Link) error {
+ s.link = link
+
+ return nil
+}
+func (s *commandStore) LinkByHash(_ context.Context, hash []byte) (Link, error) {
+ if string(hash) != string(s.link.TokenHash) {
+ return Link{}, ErrInvalidLink
+ }
+
+ return s.link, nil
+}
+func (s *commandStore) AcceptLink(_ context.Context, _ Link, userID uuid.UUID) error {
+ s.acceptedUserID = userID
+ s.externalUserID = userID
+
+ return nil
+}
+func (s *commandStore) ConversationBinding(context.Context, uuid.UUID, uuid.UUID, int64, string) (uuid.UUID, error) {
+ if s.boundRepositoryID == uuid.Nil {
+ return uuid.Nil, ErrNoBinding
+ }
+
+ return s.boundRepositoryID, nil
+}
+func (s *commandStore) BindConversation(_ context.Context, _, _ uuid.UUID, _ int64, _ string, repositoryID, _ uuid.UUID) error {
+ s.boundRepositoryID = repositoryID
+
+ return nil
+}
+func (s *commandStore) UnbindConversation(context.Context, uuid.UUID, uuid.UUID, int64, string) error {
+ s.boundRepositoryID = uuid.Nil
+
+ return nil
+}
+
+type commandAuthorizer struct{ err error }
+
+func (a commandAuthorizer) Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) {
+ return identity.Membership{Role: "member"}, a.err
+}
+
+type commandRepositories struct {
+ items []repository.Repository
+ snapshots []repository.Snapshot
+}
+
+func (r commandRepositories) Get(_ context.Context, _ auth.Actor, _ uuid.UUID, repositoryID uuid.UUID) (repository.Repository, error) {
+ for _, item := range r.items {
+ if item.ID == repositoryID {
+ return item, nil
+ }
+ }
+
+ return repository.Repository{}, errors.New("not found")
+}
+func (r commandRepositories) List(context.Context, auth.Actor, uuid.UUID, pagination.Request) (pagination.Page[repository.Repository], error) {
+ return pagination.Page[repository.Repository]{Items: r.items}, nil
+}
+func (r commandRepositories) Snapshots(context.Context, auth.Actor, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[repository.Snapshot], error) {
+ return pagination.Page[repository.Snapshot]{Items: r.snapshots}, nil
+}
+
+type commandAnalyses struct {
+ run analysis.Run
+ actor auth.Actor
+ input analysis.CreateInput
+ calls int
+}
+
+func (a *commandAnalyses) Create(_ context.Context, actor auth.Actor, _ uuid.UUID, _ uuid.UUID, input analysis.CreateInput) (analysis.Run, error) {
+ a.actor, a.input = actor, input
+ a.calls++
+
+ return a.run, nil
+}
+func (a *commandAnalyses) Get(context.Context, auth.Actor, uuid.UUID, uuid.UUID) (analysis.Run, error) {
+ return a.run, nil
+}
+
+func TestLinkRequiresWebAuthorizationAndBindsExactSender(t *testing.T) {
+ store := &commandStore{}
+ service := NewService(
+ store, commandAuthorizer{}, commandRepositories{}, &commandAnalyses{}, "https://mooncode.example/app",
+ WithClock(func() time.Time { return time.Unix(100, 0) }),
+ WithRandom(func(destination []byte) (int, error) {
+ for index := range destination {
+ destination[index] = byte(index)
+ }
+ return len(destination), nil
+ }),
+ )
+ workspaceID, channelID := uuid.New(), uuid.New()
+ response, err := service.Handle(context.Background(), workspaceID, channelID, 1, channelpkg.InboundMessage{SenderCanonicalID: "feishu:sender", Text: "link"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ location, err := url.Parse(strings.TrimSpace(response[strings.LastIndex(response, "\n")+1:]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := location.Query().Get("channelLink")
+ if token == "" || store.link.ChannelVersion != 1 || store.link.SenderCanonicalID != "feishu:sender" || store.link.ExpiresAt != time.Unix(100, 0).Add(linkLifetime) {
+ t.Fatalf("unexpected link: response=%q link=%+v", response, store.link)
+ }
+ raw := make([]byte, 32)
+ for index := range raw {
+ raw[index] = byte(index)
+ }
+ hash := sha256.Sum256(raw)
+ if string(store.link.TokenHash) != string(hash[:]) {
+ t.Fatal("link token was not stored as a hash")
+ }
+
+ actor := auth.Actor{UserID: uuid.New()}
+ if err := service.Accept(context.Background(), actor, token); err != nil {
+ t.Fatal(err)
+ }
+ if store.acceptedUserID != actor.UserID {
+ t.Fatalf("linked user = %s, want %s", store.acceptedUserID, actor.UserID)
+ }
+}
+
+func TestAnalyzeRequiresLinkedMemberAndConversationBinding(t *testing.T) {
+ workspaceID, channelID, userID, repositoryID, runID := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ store := &commandStore{externalUserID: userID, boundRepositoryID: repositoryID}
+ analyses := &commandAnalyses{run: analysis.Run{ID: runID, CommitSHA: "1234567890abcdef"}}
+ service := NewService(store, commandAuthorizer{}, commandRepositories{items: []repository.Repository{{ID: repositoryID, Name: "mooncode", Status: "ready"}}}, analyses, "https://mooncode.example/app")
+
+ response, err := service.Handle(context.Background(), workspaceID, channelID, 1, channelpkg.InboundMessage{ConversationID: "chat", SenderCanonicalID: "feishu:sender", Text: "analyze"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if analyses.calls != 1 || analyses.actor.UserID != userID || analyses.input.ProfileID != uuid.Nil || !strings.Contains(response, runID.String()) {
+ t.Fatalf("analysis was not created with linked identity: response=%q analyses=%+v", response, analyses)
+ }
+
+ blocked := &commandAnalyses{}
+ service = NewService(store, commandAuthorizer{err: errors.New("forbidden")}, commandRepositories{}, blocked, "https://mooncode.example/app")
+ response, err = service.Handle(context.Background(), workspaceID, channelID, 1, channelpkg.InboundMessage{ConversationID: "chat", SenderCanonicalID: "feishu:sender", Text: "analyze"})
+ if err != nil || blocked.calls != 0 || !strings.Contains(response, "no longer has access") {
+ t.Fatalf("removed member command was not blocked: response=%q error=%v calls=%d", response, err, blocked.calls)
+ }
+}
+
+func TestUnlinkedSenderCannotAnalyze(t *testing.T) {
+ analyses := &commandAnalyses{}
+ service := NewService(&commandStore{}, commandAuthorizer{}, commandRepositories{}, analyses, "https://mooncode.example/app")
+
+ response, err := service.Handle(context.Background(), uuid.New(), uuid.New(), 1, channelpkg.InboundMessage{SenderCanonicalID: "dingtalk:sender", Text: "analyze"})
+ if err != nil || analyses.calls != 0 || !strings.Contains(response, "not linked") {
+ t.Fatalf("unlinked command was not rejected: response=%q error=%v calls=%d", response, err, analyses.calls)
+ }
+}
+
+func TestIdentityLookupFailureIsNotReportedAsUnlinked(t *testing.T) {
+ databaseError := errors.New("database unavailable")
+ service := NewService(&commandStore{externalError: databaseError}, commandAuthorizer{}, commandRepositories{}, &commandAnalyses{}, "https://mooncode.example/app")
+
+ response, err := service.Handle(context.Background(), uuid.New(), uuid.New(), 1, channelpkg.InboundMessage{SenderCanonicalID: "dingtalk:sender", Text: "repos"})
+ if response != "" || !errors.Is(err, databaseError) {
+ t.Fatalf("identity lookup = (%q, %v), want empty response and database error", response, err)
+ }
+}
diff --git a/internal/channel/data/audit_integration_test.go b/internal/channel/data/audit_integration_test.go
new file mode 100644
index 0000000..e2e7d64
--- /dev/null
+++ b/internal/channel/data/audit_integration_test.go
@@ -0,0 +1,102 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestChannelMutationsPersistSanitizedAuditEvents(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ actorID, workspaceID := uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, actorID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Channel audit',$2,$3)`, workspaceID, "channel-audit-"+workspaceID.String(), actorID); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ config := json.RawMessage(`{"values":{"app_id":"sensitive-app-id","receive_id":"chat"},"senderAllowList":["*"]}`)
+ createdEvents := []string{"analysis.failed", "analysis.succeeded"}
+ created, err := store.Create(ctx, workspaceID, actorID, "feishu", "Audit", []byte("encrypted-channel-secret"), []byte("secret-nonce"), 1, config, createdEvents)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(created.NotificationEvents, createdEvents) {
+ t.Fatalf("created notification events = %v, want %v", created.NotificationEvents, createdEvents)
+ }
+ updatedEvents := []string{"analysis.started"}
+ updated, err := store.Update(ctx, workspaceID, actorID, created.ID, "Audit updated", config, updatedEvents, created.ConfigVersion)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(updated.NotificationEvents, updatedEvents) {
+ t.Fatalf("updated notification events = %v, want %v", updated.NotificationEvents, updatedEvents)
+ }
+ rotated, err := store.RotateCredential(ctx, workspaceID, actorID, created.ID, []byte("replacement-encrypted-secret"), []byte("replacement-nonce"), 2, updated.ConfigVersion)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(rotated.NotificationEvents, updatedEvents) {
+ t.Fatalf("rotated channel notification events = %v, want %v", rotated.NotificationEvents, updatedEvents)
+ }
+ if _, err := store.Enable(ctx, workspaceID, actorID, created.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ if deleted, err := store.Delete(ctx, workspaceID, actorID, created.ID); err != nil || !deleted {
+ t.Fatalf("Delete() = (%v, %v), want (true, nil)", deleted, err)
+ }
+
+ wantActions := map[string]int{
+ audit.ActionChannelCreated: 1,
+ audit.ActionChannelConfigurationUpdated: 1,
+ audit.ActionChannelCredentialRotated: 1,
+ audit.ActionChannelEnabled: 1,
+ audit.ActionChannelDeleted: 1,
+ }
+ rows, err := pool.Query(ctx, `SELECT workspace_id,actor_user_id,action,metadata::text FROM audit_events WHERE resource_type=$1 AND resource_id=$2`, audit.ResourceChannel, created.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ seen := make(map[string]int)
+ for rows.Next() {
+ var eventWorkspaceID, eventActorID uuid.UUID
+ var action, metadata string
+ if err := rows.Scan(&eventWorkspaceID, &eventActorID, &action, &metadata); err != nil {
+ t.Fatal(err)
+ }
+ if eventWorkspaceID != workspaceID || eventActorID != actorID {
+ t.Fatalf("audit identity = (%s, %s), want (%s, %s)", eventWorkspaceID, eventActorID, workspaceID, actorID)
+ }
+ if strings.Contains(metadata, "sensitive-app-id") || strings.Contains(metadata, "encrypted-channel-secret") || strings.Contains(metadata, "secret-nonce") || strings.Contains(metadata, "replacement-encrypted-secret") || strings.Contains(metadata, "replacement-nonce") {
+ t.Fatalf("channel audit metadata leaked sensitive data: %s", metadata)
+ }
+ seen[action]++
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ for action, count := range wantActions {
+ if seen[action] != count {
+ t.Fatalf("audit action %q count = %d, want %d; all=%v (final version %d)", action, seen[action], count, seen, rotated.ConfigVersion)
+ }
+ }
+}
diff --git a/internal/channel/data/command_store.go b/internal/channel/data/command_store.go
new file mode 100644
index 0000000..8458895
--- /dev/null
+++ b/internal/channel/data/command_store.go
@@ -0,0 +1,129 @@
+package data
+
+import (
+ "context"
+ "errors"
+
+ channelcommand "github.com/fuchencong/mooncode/internal/channel/command"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func (s *Store) CheckActiveChannel(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64) error {
+ _, err := s.queries.CheckActiveChannelVersion(ctx, sqlc.CheckActiveChannelVersionParams{
+ ID: channelID, WorkspaceID: workspaceID, ConfigVersion: channelVersion,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return channelcommand.ErrChannelInactive
+ }
+
+ return err
+}
+
+func (s *Store) ExternalUserID(ctx context.Context, channelID uuid.UUID, senderCanonicalID string) (uuid.UUID, error) {
+ userID, err := s.queries.GetChannelExternalIdentity(ctx, sqlc.GetChannelExternalIdentityParams{ChannelID: channelID, SenderCanonicalID: senderCanonicalID})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return uuid.Nil, channelcommand.ErrNotLinked
+ }
+
+ return userID, err
+}
+
+func (s *Store) CreateLink(ctx context.Context, link channelcommand.Link) error {
+ _, err := s.queries.CreateChannelIdentityLink(ctx, sqlc.CreateChannelIdentityLinkParams{
+ ID: link.ID, WorkspaceID: link.WorkspaceID, ChannelID: link.ChannelID, ChannelVersion: link.ChannelVersion,
+ SenderCanonicalID: link.SenderCanonicalID, TokenHash: link.TokenHash,
+ ExpiresAt: pgtype.Timestamptz{Time: link.ExpiresAt, Valid: true},
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return channelcommand.ErrChannelInactive
+ }
+
+ return err
+}
+
+func (s *Store) LinkByHash(ctx context.Context, hash []byte) (channelcommand.Link, error) {
+ row, err := s.queries.GetChannelIdentityLink(ctx, hash)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return channelcommand.Link{}, channelcommand.ErrInvalidLink
+ }
+ if err != nil {
+ return channelcommand.Link{}, err
+ }
+
+ return channelcommand.Link{ID: row.ID, WorkspaceID: row.WorkspaceID, ChannelID: row.ChannelID, ChannelVersion: row.ChannelVersion, SenderCanonicalID: row.SenderCanonicalID, TokenHash: row.TokenHash, ExpiresAt: row.ExpiresAt.Time}, nil
+}
+
+func (s *Store) AcceptLink(ctx context.Context, link channelcommand.Link, userID uuid.UUID) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ queries := s.queries.WithTx(tx)
+ count, err := queries.ConsumeChannelIdentityLink(ctx, link.ID)
+ if err != nil {
+ return err
+ }
+ if count != 1 {
+ return channelcommand.ErrInvalidLink
+ }
+ if err := queries.UpsertChannelExternalIdentity(ctx, sqlc.UpsertChannelExternalIdentityParams{
+ ID: uuid.New(), WorkspaceID: link.WorkspaceID, ChannelID: link.ChannelID,
+ SenderCanonicalID: link.SenderCanonicalID, UserID: userID,
+ }); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (s *Store) ConversationBinding(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string) (uuid.UUID, error) {
+ conversationID, err := s.activeConversationID(ctx, workspaceID, channelID, channelVersion, conversationExternalID)
+ if err != nil {
+ return uuid.Nil, err
+ }
+
+ repositoryID, err := s.queries.GetConversationBinding(ctx, conversationID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return uuid.Nil, channelcommand.ErrNoBinding
+ }
+
+ return repositoryID, err
+}
+
+func (s *Store) BindConversation(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string, repositoryID, userID uuid.UUID) error {
+ conversationID, err := s.activeConversationID(ctx, workspaceID, channelID, channelVersion, conversationExternalID)
+ if err != nil {
+ return err
+ }
+
+ return s.queries.UpsertConversationBinding(ctx, sqlc.UpsertConversationBindingParams{ConversationID: conversationID, WorkspaceID: workspaceID, RepositoryID: repositoryID, BoundBy: userID})
+}
+
+func (s *Store) UnbindConversation(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string) error {
+ conversationID, err := s.activeConversationID(ctx, workspaceID, channelID, channelVersion, conversationExternalID)
+ if err != nil {
+ return err
+ }
+ count, err := s.queries.DeleteConversationBinding(ctx, conversationID)
+ if errors.Is(err, pgx.ErrNoRows) || (err == nil && count == 0) {
+ return nil
+ }
+
+ return err
+}
+
+func (s *Store) activeConversationID(ctx context.Context, workspaceID, channelID uuid.UUID, channelVersion int64, conversationExternalID string) (uuid.UUID, error) {
+ conversationID, err := s.queries.GetConversationIDByExternal(ctx, sqlc.GetConversationIDByExternalParams{
+ ChannelID: channelID, ExternalID: conversationExternalID,
+ WorkspaceID: workspaceID, ChannelVersion: channelVersion,
+ })
+ if errors.Is(err, pgx.ErrNoRows) {
+ return uuid.Nil, channelcommand.ErrChannelInactive
+ }
+
+ return conversationID, err
+}
diff --git a/internal/channel/data/command_store_integration_test.go b/internal/channel/data/command_store_integration_test.go
new file mode 100644
index 0000000..402bbe2
--- /dev/null
+++ b/internal/channel/data/command_store_integration_test.go
@@ -0,0 +1,146 @@
+//go:build integration
+
+package data
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ channelcommand "github.com/fuchencong/mooncode/internal/channel/command"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestCommandStorePersistsSecureScopedState(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID := uuid.New()
+ workspaceID := uuid.New()
+ channelID := uuid.New()
+ otherChannelID := uuid.New()
+ repositoryID := uuid.New()
+ conversationID := uuid.New()
+ conversationExternalID := "conversation-" + uuid.NewString()
+ senderCanonicalID := "feishu:ou_" + uuid.NewString()
+
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Command store',$2,$3)`, workspaceID, "command-store-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by)
+VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), "/tmp/"+repositoryID.String()+".git", userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO channels (id,workspace_id,type,name,enabled,runtime_status,config_version)
+VALUES ($1,$2,'feishu','primary',true,'connected',1),($3,$2,'feishu','other',true,'connected',1)`, channelID, workspaceID, otherChannelID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO conversations (id,workspace_id,channel_id,external_id,type,title)
+VALUES ($1,$2,$3,$4,'group','Engineering')`, conversationID, workspaceID, channelID, conversationExternalID); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ rawToken := []byte("raw-channel-link-token-" + uuid.NewString())
+ tokenHash := sha256.Sum256(rawToken)
+ link := channelcommand.Link{
+ ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: 1,
+ SenderCanonicalID: senderCanonicalID, TokenHash: tokenHash[:], ExpiresAt: time.Now().Add(time.Minute),
+ }
+ if err := store.CreateLink(ctx, link); err != nil {
+ t.Fatal(err)
+ }
+
+ var storedHash []byte
+ if err := pool.QueryRow(ctx, `SELECT token_hash FROM channel_identity_links WHERE id=$1`, link.ID).Scan(&storedHash); err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(storedHash, tokenHash[:]) || bytes.Equal(storedHash, rawToken) {
+ t.Fatal("channel identity link did not persist only the token hash")
+ }
+
+ loaded, err := store.LinkByHash(ctx, tokenHash[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.ChannelID != channelID || loaded.ChannelVersion != 1 || loaded.SenderCanonicalID != senderCanonicalID {
+ t.Fatalf("link scope changed during persistence: %+v", loaded)
+ }
+ if err := store.AcceptLink(ctx, loaded, userID); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.AcceptLink(ctx, loaded, userID); !errors.Is(err, channelcommand.ErrInvalidLink) {
+ t.Fatalf("second link consumption error = %v, want ErrInvalidLink", err)
+ }
+
+ linkedUserID, err := store.ExternalUserID(ctx, channelID, senderCanonicalID)
+ if err != nil || linkedUserID != userID {
+ t.Fatalf("exact external identity = (%s, %v), want (%s, nil)", linkedUserID, err, userID)
+ }
+ if _, err := store.ExternalUserID(ctx, channelID, senderCanonicalID+"-other"); !errors.Is(err, channelcommand.ErrNotLinked) {
+ t.Fatalf("different sender error = %v, want ErrNotLinked", err)
+ }
+ if _, err := store.ExternalUserID(ctx, otherChannelID, senderCanonicalID); !errors.Is(err, channelcommand.ErrNotLinked) {
+ t.Fatalf("different channel error = %v, want ErrNotLinked", err)
+ }
+
+ if err := store.BindConversation(ctx, workspaceID, channelID, 1, conversationExternalID, repositoryID, userID); err != nil {
+ t.Fatal(err)
+ }
+ persistedStore := NewStore(pool)
+ boundRepositoryID, err := persistedStore.ConversationBinding(ctx, workspaceID, channelID, 1, conversationExternalID)
+ if err != nil || boundRepositoryID != repositoryID {
+ t.Fatalf("persisted binding = (%s, %v), want (%s, nil)", boundRepositoryID, err, repositoryID)
+ }
+
+ staleHash := sha256.Sum256([]byte("stale-" + uuid.NewString()))
+ staleLink := channelcommand.Link{
+ ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: 1,
+ SenderCanonicalID: senderCanonicalID, TokenHash: staleHash[:], ExpiresAt: time.Now().Add(time.Minute),
+ }
+ if err := store.CreateLink(ctx, staleLink); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE channels SET config_version=2 WHERE id=$1`, channelID); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.CheckActiveChannel(ctx, workspaceID, channelID, 1); !errors.Is(err, channelcommand.ErrChannelInactive) {
+ t.Fatalf("stale channel check error = %v, want ErrChannelInactive", err)
+ }
+ if _, err := store.LinkByHash(ctx, staleHash[:]); !errors.Is(err, channelcommand.ErrInvalidLink) {
+ t.Fatalf("stale link error = %v, want ErrInvalidLink", err)
+ }
+ if err := store.BindConversation(ctx, workspaceID, channelID, 1, conversationExternalID, repositoryID, userID); !errors.Is(err, channelcommand.ErrChannelInactive) {
+ t.Fatalf("stale bind error = %v, want ErrChannelInactive", err)
+ }
+
+ if _, err := pool.Exec(ctx, `UPDATE channels SET enabled=false,runtime_status='disabled' WHERE id=$1`, channelID); err != nil {
+ t.Fatal(err)
+ }
+ disabledLink := channelcommand.Link{
+ ID: uuid.New(), WorkspaceID: workspaceID, ChannelID: channelID, ChannelVersion: 2,
+ SenderCanonicalID: senderCanonicalID, TokenHash: []byte(uuid.NewString()), ExpiresAt: time.Now().Add(time.Minute),
+ }
+ if err := store.CreateLink(ctx, disabledLink); !errors.Is(err, channelcommand.ErrChannelInactive) {
+ t.Fatalf("disabled link creation error = %v, want ErrChannelInactive", err)
+ }
+ if err := store.UnbindConversation(ctx, workspaceID, channelID, 2, conversationExternalID); !errors.Is(err, channelcommand.ErrChannelInactive) {
+ t.Fatalf("disabled unbind error = %v, want ErrChannelInactive", err)
+ }
+}
diff --git a/internal/channel/data/delivery_store.go b/internal/channel/data/delivery_store.go
new file mode 100644
index 0000000..148b4e3
--- /dev/null
+++ b/internal/channel/data/delivery_store.go
@@ -0,0 +1,54 @@
+package data
+
+import (
+ "context"
+
+ channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func (s *Store) LoadDelivery(ctx context.Context, notificationID uuid.UUID) (channelworkflow.WorkItem, error) {
+ var item channelworkflow.WorkItem
+ var keyVersion pgtype.Int4
+ err := s.pool.QueryRow(ctx, `
+SELECT n.id,n.status,n.event_type,n.workspace_id,n.analysis_run_id,n.channel_id,
+ c.type,c.name,c.enabled,c.secret_ciphertext,c.secret_nonce,c.key_version,c.config,
+ r.name,a.commit_sha,COALESCE(a.failed_stage,''),COALESCE(a.error_message,''),COALESCE(report.result,'{}'::jsonb)
+FROM notifications n
+JOIN channels c ON c.id=n.channel_id AND c.workspace_id=n.workspace_id
+JOIN analysis_runs a ON a.id=n.analysis_run_id AND a.workspace_id=n.workspace_id
+JOIN repositories r ON r.id=a.repository_id AND r.workspace_id=n.workspace_id
+LEFT JOIN analysis_reports report ON report.id=a.report_id AND report.workspace_id=n.workspace_id
+WHERE n.id=$1`, notificationID).Scan(
+ &item.NotificationID, &item.Status, &item.EventType, &item.WorkspaceID, &item.AnalysisRunID, &item.ChannelID,
+ &item.ChannelType, &item.ChannelName, &item.ChannelEnabled, &item.Ciphertext, &item.Nonce, &keyVersion, &item.Config,
+ &item.RepositoryName, &item.CommitSHA, &item.FailedStage, &item.ErrorMessage, &item.Result,
+ )
+ item.KeyVersion = int(keyVersion.Int32)
+
+ return item, err
+}
+
+func (s *Store) StartDelivery(ctx context.Context, notificationID uuid.UUID) error {
+ _, err := s.queries.StartNotification(ctx, notificationID)
+
+ return err
+}
+
+func (s *Store) FinishDelivery(ctx context.Context, notificationID uuid.UUID) error {
+ _, err := s.queries.FinishNotification(ctx, notificationID)
+
+ return err
+}
+
+func (s *Store) CancelDelivery(ctx context.Context, notificationID uuid.UUID) error {
+ return s.queries.CancelNotification(ctx, notificationID)
+}
+
+func (s *Store) FailDelivery(ctx context.Context, notificationID uuid.UUID, message string) error {
+ return s.queries.FailNotification(ctx, sqlc.FailNotificationParams{
+ ID: notificationID, LastErrorMessage: pgtype.Text{String: message, Valid: message != ""},
+ })
+}
diff --git a/internal/channel/data/runtime_integration_test.go b/internal/channel/data/runtime_integration_test.go
new file mode 100644
index 0000000..2c13d39
--- /dev/null
+++ b/internal/channel/data/runtime_integration_test.go
@@ -0,0 +1,87 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "testing"
+ "time"
+
+ channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+)
+
+func TestRuntimePersistsInboundMessageExactlyOnce(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID, channelID := uuid.New(), uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Channel runtime',$2,$3)`, workspaceID, "channel-runtime-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ config := json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`)
+ if _, err := pool.Exec(ctx, `
+INSERT INTO channels (id,workspace_id,type,name,enabled,runtime_status,secret_ciphertext,secret_nonce,key_version,config)
+VALUES ($1,$2,'feishu','runtime',true,'starting',$3,$4,1,$5)`, channelID, workspaceID, []byte("ciphertext"), []byte("nonce"), config); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ configurations, err := store.ListRuntimeChannels(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var runtimeConfig channelruntime.Configuration
+ for _, item := range configurations {
+ if item.ID == channelID {
+ runtimeConfig = item
+ break
+ }
+ }
+ if runtimeConfig.ID != channelID || runtimeConfig.Version != 1 {
+ t.Fatalf("runtime configuration not found: %#v", configurations)
+ }
+ message := channelpkg.InboundMessage{
+ ExternalID: "external-message", ConversationID: "external-conversation", ConversationType: "group",
+ ConversationTitle: "Engineering", SenderCanonicalID: "feishu:ou_user", SenderDisplayName: "Ada",
+ Text: "analyze", Mentioned: true, OccurredAt: time.Now().UTC(),
+ }
+ inserted, err := store.SaveInbound(ctx, runtimeConfig, message)
+ if err != nil || !inserted {
+ t.Fatalf("first SaveInbound() = (%v, %v), want (true, nil)", inserted, err)
+ }
+ inserted, err = store.SaveInbound(ctx, runtimeConfig, message)
+ if err != nil || inserted {
+ t.Fatalf("duplicate SaveInbound() = (%v, %v), want (false, nil)", inserted, err)
+ }
+ var conversations, messages int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM conversations WHERE channel_id=$1`, channelID).Scan(&conversations); err != nil {
+ t.Fatal(err)
+ }
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM messages WHERE channel_id=$1`, channelID).Scan(&messages); err != nil {
+ t.Fatal(err)
+ }
+ if conversations != 1 || messages != 1 {
+ t.Fatalf("expected one conversation and message, got %d and %d", conversations, messages)
+ }
+
+ if _, err := pool.Exec(ctx, `UPDATE channels SET enabled=false, runtime_status='disabled' WHERE id=$1`, channelID); err != nil {
+ t.Fatal(err)
+ }
+ message.ExternalID = "stale-message"
+ inserted, err = store.SaveInbound(ctx, runtimeConfig, message)
+ if err != nil || inserted {
+ t.Fatalf("stale SaveInbound() = (%v, %v), want (false, nil)", inserted, err)
+ }
+}
diff --git a/internal/channel/data/runtime_store.go b/internal/channel/data/runtime_store.go
new file mode 100644
index 0000000..f6b764c
--- /dev/null
+++ b/internal/channel/data/runtime_store.go
@@ -0,0 +1,90 @@
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+
+ channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func (s *Store) ListRuntimeChannels(ctx context.Context) ([]channelruntime.Configuration, error) {
+ rows, err := s.queries.ListRuntimeChannels(ctx)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]channelruntime.Configuration, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, channelruntime.Configuration{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, Type: row.Type, Version: row.ConfigVersion,
+ Ciphertext: row.SecretCiphertext, Nonce: row.SecretNonce, KeyVersion: int(row.KeyVersion.Int32), Config: row.Config,
+ })
+ }
+
+ return items, nil
+}
+
+func (s *Store) SaveInbound(ctx context.Context, configuration channelruntime.Configuration, message channelpkg.InboundMessage) (bool, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ queries := s.queries.WithTx(tx)
+
+ version, err := queries.LockChannelRuntime(ctx, sqlc.LockChannelRuntimeParams{ID: configuration.ID, WorkspaceID: configuration.WorkspaceID})
+ if errors.Is(err, pgx.ErrNoRows) || (err == nil && version != configuration.Version) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ conversationID, err := queries.UpsertConversation(ctx, sqlc.UpsertConversationParams{
+ ID: uuid.New(), WorkspaceID: configuration.WorkspaceID, ChannelID: configuration.ID,
+ ExternalID: message.ConversationID, Type: message.ConversationType, Title: message.ConversationTitle,
+ })
+ if err != nil {
+ return false, err
+ }
+ content, err := json.Marshal(map[string]string{"type": "text", "text": message.Text})
+ if err != nil {
+ return false, err
+ }
+ count, err := queries.InsertInboundMessage(ctx, sqlc.InsertInboundMessageParams{
+ ID: uuid.New(), WorkspaceID: configuration.WorkspaceID, ChannelID: configuration.ID,
+ ConversationID: conversationID, ExternalID: message.ExternalID,
+ SenderCanonicalID: message.SenderCanonicalID, SenderDisplayName: message.SenderDisplayName,
+ Content: content, OccurredAt: pgtype.Timestamptz{Time: message.OccurredAt, Valid: true},
+ })
+ if err != nil {
+ return false, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return false, err
+ }
+
+ return count > 0, nil
+}
+
+func (s *Store) MarkRuntimeConnected(ctx context.Context, id uuid.UUID, version int64) error {
+ count, err := s.queries.MarkChannelRuntimeConnected(ctx, sqlc.MarkChannelRuntimeConnectedParams{ID: id, ConfigVersion: version})
+ if err == nil && count == 0 {
+ return fmt.Errorf("channel %s configuration is no longer active", id)
+ }
+
+ return err
+}
+
+func (s *Store) MarkRuntimeError(ctx context.Context, id uuid.UUID, version int64, message string) error {
+ _, err := s.queries.MarkChannelRuntimeError(ctx, sqlc.MarkChannelRuntimeErrorParams{
+ ID: id, ConfigVersion: version, LastErrorMessage: pgtype.Text{String: message, Valid: message != ""},
+ })
+
+ return err
+}
diff --git a/internal/channel/data/store.go b/internal/channel/data/store.go
new file mode 100644
index 0000000..1253574
--- /dev/null
+++ b/internal/channel/data/store.go
@@ -0,0 +1,309 @@
+package data
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ channel "github.com/fuchencong/mooncode/internal/channel/biz"
+ "github.com/fuchencong/mooncode/internal/data/pagecursor"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ pool *pgxpool.Pool
+ queries *sqlc.Queries
+}
+
+func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool, queries: sqlc.New(pool)} }
+func (s *Store) List(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[channel.Channel], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListChannels(ctx, sqlc.ListChannelsParams{
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[channel.Channel]{}, err
+ }
+ subscriptions, err := channelSubscriptions(ctx, s.queries, workspaceID)
+ if err != nil {
+ return pagination.Page[channel.Channel]{}, err
+ }
+ items := make([]channel.Channel, 0, len(rows))
+ for _, row := range rows {
+ item := mapChannel(row)
+ item.NotificationEvents = subscriptions[row.ID]
+ items = append(items, item)
+ }
+ return pagination.Build(items, page.Limit, func(item channel.Channel) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+func (s *Store) Create(ctx context.Context, workspaceID, actorID uuid.UUID, kind, name string, ciphertext, nonce []byte, keyVersion int, config json.RawMessage, notificationEvents []string) (channel.Channel, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.CreateChannel(ctx, sqlc.CreateChannelParams{ID: uuid.New(), WorkspaceID: workspaceID, Type: kind, Name: name, SecretCiphertext: ciphertext, SecretNonce: nonce, KeyVersion: pgtype.Int4{Int32: int32(keyVersion), Valid: len(ciphertext) > 0}, Config: config})
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ if err = replaceChannelSubscriptions(ctx, q, workspaceID, row.ID, notificationEvents); err != nil {
+ return channel.Channel{}, err
+ }
+ if err = recordChannel(ctx, q, workspaceID, actorID, row.ID, audit.ActionChannelCreated, map[string]any{"channelType": row.Type, "notificationEventCount": len(notificationEvents)}); err != nil {
+ return channel.Channel{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return channel.Channel{}, err
+ }
+
+ item := mapChannel(row)
+ item.NotificationEvents = notificationEvents
+
+ return item, nil
+}
+func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (channel.Channel, error) {
+ row, err := s.queries.GetChannel(ctx, sqlc.GetChannelParams{ID: id, WorkspaceID: workspaceID})
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ events, err := channelSubscriptionEvents(ctx, s.queries, workspaceID, id)
+ item := mapChannel(row)
+ item.NotificationEvents = events
+
+ return item, err
+}
+func (s *Store) Update(ctx context.Context, workspaceID, actorID, id uuid.UUID, name string, config json.RawMessage, notificationEvents []string, configVersion int64) (channel.Channel, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.UpdateChannel(ctx, sqlc.UpdateChannelParams{ID: id, WorkspaceID: workspaceID, Name: name, Config: config, ConfigVersion: configVersion})
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ if err = replaceChannelSubscriptions(ctx, q, workspaceID, id, notificationEvents); err != nil {
+ return channel.Channel{}, err
+ }
+ if err = recordChannel(ctx, q, workspaceID, actorID, id, audit.ActionChannelConfigurationUpdated, map[string]any{"configVersion": row.ConfigVersion, "notificationEventCount": len(notificationEvents)}); err != nil {
+ return channel.Channel{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return channel.Channel{}, err
+ }
+
+ item := mapChannel(row)
+ item.NotificationEvents = notificationEvents
+
+ return item, nil
+}
+func (s *Store) RotateCredential(ctx context.Context, workspaceID, actorID, id uuid.UUID, ciphertext, nonce []byte, keyVersion int, configVersion int64) (channel.Channel, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.RotateChannelCredential(ctx, sqlc.RotateChannelCredentialParams{
+ ID: id, WorkspaceID: workspaceID, SecretCiphertext: ciphertext, SecretNonce: nonce,
+ KeyVersion: pgtype.Int4{Int32: int32(keyVersion), Valid: true}, ConfigVersion: configVersion,
+ })
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ events, err := channelSubscriptionEvents(ctx, q, workspaceID, id)
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ if err = recordChannel(ctx, q, workspaceID, actorID, id, audit.ActionChannelCredentialRotated, map[string]any{"configVersion": row.ConfigVersion}); err != nil {
+ return channel.Channel{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return channel.Channel{}, err
+ }
+ item := mapChannel(row)
+ item.NotificationEvents = events
+
+ return item, nil
+}
+func (s *Store) Enable(ctx context.Context, workspaceID, actorID, id uuid.UUID, enabled bool) (channel.Channel, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.SetChannelEnabled(ctx, sqlc.SetChannelEnabledParams{ID: id, WorkspaceID: workspaceID, Enabled: enabled})
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ events, err := channelSubscriptionEvents(ctx, q, workspaceID, id)
+ if err != nil {
+ return channel.Channel{}, err
+ }
+ action := audit.ActionChannelDisabled
+ if enabled {
+ action = audit.ActionChannelEnabled
+ }
+ if err = recordChannel(ctx, q, workspaceID, actorID, id, action, map[string]any{"configVersion": row.ConfigVersion}); err != nil {
+ return channel.Channel{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return channel.Channel{}, err
+ }
+ item := mapChannel(row)
+ item.NotificationEvents = events
+
+ return item, nil
+}
+func (s *Store) Delete(ctx context.Context, workspaceID, actorID, id uuid.UUID) (bool, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ if err = q.CancelChannelNotifications(ctx, sqlc.CancelChannelNotificationsParams{
+ ChannelID: id, WorkspaceID: workspaceID,
+ }); err != nil {
+ return false, err
+ }
+ count, err := q.DeleteChannel(ctx, sqlc.DeleteChannelParams{ID: id, WorkspaceID: workspaceID})
+ if err != nil || count == 0 {
+ return false, err
+ }
+ if err = recordChannel(ctx, q, workspaceID, actorID, id, audit.ActionChannelDeleted, nil); err != nil {
+ return false, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
+
+func recordChannel(ctx context.Context, writer audit.Writer, workspaceID, actorID, channelID uuid.UUID, action string, metadata map[string]any) error {
+ return audit.Record(ctx, writer, audit.Event{
+ WorkspaceID: workspaceID,
+ ActorUserID: actorID,
+ Action: action,
+ Resource: audit.ResourceChannel,
+ ResourceID: channelID,
+ Metadata: metadata,
+ })
+}
+func (s *Store) Conversations(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[channel.Conversation], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListConversations(ctx, sqlc.ListConversationsParams{
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[channel.Conversation]{}, err
+ }
+ items := make([]channel.Conversation, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, channel.Conversation{ID: row.ID, ChannelInstanceID: row.ChannelID, ChannelName: row.ChannelName, ChannelType: row.ChannelType, ExternalID: row.ExternalID, Type: row.Type, Title: row.Title, CreatedAt: row.CreatedAt.Time})
+ }
+
+ return pagination.Build(items, page.Limit, func(item channel.Conversation) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+func (s *Store) Messages(ctx context.Context, workspaceID uuid.UUID, channelID, conversationID uuid.NullUUID, page pagination.Request) (pagination.Page[channel.Message], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListMessages(ctx, sqlc.ListMessagesParams{
+ WorkspaceID: workspaceID,
+ ChannelID: channelID,
+ ConversationID: conversationID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[channel.Message]{}, err
+ }
+ items := make([]channel.Message, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, channel.Message{ID: row.ID, ChannelInstanceID: row.ChannelID, ChannelName: row.ChannelName, ChannelType: row.ChannelType, ConversationID: row.ConversationID, ConversationExternalID: row.ConversationExternalID, SenderCanonicalID: row.SenderCanonicalID, SenderDisplayName: row.SenderDisplayName, ConversationType: row.ConversationType, Content: row.Content, OccurredAt: row.OccurredAt.Time})
+ }
+ return pagination.Build(items, page.Limit, func(item channel.Message) (time.Time, uuid.UUID) {
+ return item.OccurredAt, item.ID
+ }), nil
+}
+func (s *Store) Overview(ctx context.Context, workspaceID uuid.UUID) (channel.Overview, error) {
+ row, err := s.queries.GetWorkspaceOverview(ctx, workspaceID)
+ if err != nil {
+ return channel.Overview{}, err
+ }
+ messages, _ := s.Messages(ctx, workspaceID, uuid.NullUUID{}, uuid.NullUUID{}, pagination.Request{Limit: 5})
+ return channel.Overview{RepositoryCount: row.RepositoryCount, RepositoryWithoutCodeCount: row.RepositoryWithoutCodeCount, RecentFailedSyncCount: row.RecentFailedSyncCount, ActiveAnalysisCount: row.ActiveAnalysisCount, ActiveChannelCount: row.ActiveChannelCount, RecentMessages: messages.Items}, nil
+}
+func mapChannel(row sqlc.Channel) channel.Channel {
+ return channel.Channel{ID: row.ID, Type: row.Type, Name: row.Name, Enabled: row.Enabled, RuntimeStatus: row.RuntimeStatus, SecretConfigured: len(row.SecretCiphertext) > 0, ConfigVersion: row.ConfigVersion, Config: row.Config, LastConnectedAt: optionalTime(row.LastConnectedAt), LastErrorMessage: row.LastErrorMessage.String, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time}
+}
+
+func replaceChannelSubscriptions(ctx context.Context, queries *sqlc.Queries, workspaceID, channelID uuid.UUID, events []string) error {
+ if err := queries.DeleteChannelSubscriptions(ctx, sqlc.DeleteChannelSubscriptionsParams{
+ WorkspaceID: workspaceID,
+ ChannelID: channelID,
+ }); err != nil {
+ return err
+ }
+ for _, event := range events {
+ if err := queries.CreateChannelSubscription(ctx, sqlc.CreateChannelSubscriptionParams{
+ ID: uuid.New(),
+ WorkspaceID: workspaceID,
+ ChannelID: channelID,
+ EventType: event,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func channelSubscriptionEvents(ctx context.Context, queries *sqlc.Queries, workspaceID, channelID uuid.UUID) ([]string, error) {
+ subscriptions, err := channelSubscriptions(ctx, queries, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ return subscriptions[channelID], nil
+}
+
+func channelSubscriptions(ctx context.Context, queries *sqlc.Queries, workspaceID uuid.UUID) (map[uuid.UUID][]string, error) {
+ rows, err := queries.ListChannelSubscriptions(ctx, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ subscriptions := make(map[uuid.UUID][]string)
+ for _, row := range rows {
+ subscriptions[row.ChannelID] = append(subscriptions[row.ChannelID], row.EventType)
+ }
+
+ return subscriptions, nil
+}
+
+func optionalTime(value pgtype.Timestamptz) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Time
+ return &result
+}
diff --git a/internal/channel/manager.go b/internal/channel/manager.go
deleted file mode 100644
index 41490c5..0000000
--- a/internal/channel/manager.go
+++ /dev/null
@@ -1,409 +0,0 @@
-// Package channel owns MoonCode-specific Channel runtime supervision.
-package channel
-
-import (
- "context"
- "errors"
- "fmt"
- "math/rand/v2"
- "runtime/debug"
- "sync"
- "sync/atomic"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/sanitize"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/codes"
- "go.opentelemetry.io/otel/trace"
- "go.opentelemetry.io/otel/trace/noop"
-)
-
-type ManagerOption func(*Manager)
-
-type Observer interface {
- AddChannelRuntime(channelType, state string, delta float64)
- ObserveChannelReconnect(channelType, result string)
- ObserveChannelMessage(channelType, result string)
-}
-
-func WithClock(clock func() time.Time) ManagerOption {
- return func(manager *Manager) {
- if clock != nil {
- manager.clock = clock
- }
- }
-}
-
-func WithObserver(observer Observer) ManagerOption {
- return func(manager *Manager) { manager.observer = observer }
-}
-
-func WithTracerProvider(provider trace.TracerProvider) ManagerOption {
- return func(manager *Manager) {
- if provider != nil {
- manager.tracer = provider.Tracer("github.com/mooncode-ai/mooncode/internal/channel")
- }
- }
-}
-
-func WithRetryJitter(jitter func(time.Duration) time.Duration) ManagerOption {
- return func(manager *Manager) {
- if jitter != nil {
- manager.jitter = jitter
- }
- }
-}
-
-type Manager struct {
- store repository.ChannelStore
- secrets secretstore.SecretStore
- registry *channelcore.Registry
- owner string
- cfg config.ChannelsConfig
- logger zerolog.Logger
- clock func() time.Time
- ready atomic.Bool
- observer Observer
- jitter func(time.Duration) time.Duration
- tracer trace.Tracer
-}
-
-func NewManager(store repository.ChannelStore, secrets secretstore.SecretStore, registry *channelcore.Registry, owner string, cfg config.Config, logger zerolog.Logger, opts ...ManagerOption) *Manager {
- provider := noop.NewTracerProvider()
- manager := &Manager{store: store, secrets: secrets, registry: registry, owner: owner, cfg: cfg.Channels, logger: logger, clock: time.Now, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/internal/channel"), jitter: func(delay time.Duration) time.Duration {
- return time.Duration(float64(delay) * (0.8 + rand.Float64()*0.4))
- }}
- for _, option := range opts {
- if option != nil {
- option(manager)
- }
- }
- return manager
-}
-func (m *Manager) Ready() bool { return m.ready.Load() }
-
-type activeRuntime struct {
- instance model.ChannelInstance
- lease model.ChannelLease
- cancel context.CancelFunc
- sink *runtimeSink
- done chan struct{}
- started time.Time
- attempt int
-}
-type runtimeResult struct {
- id uuid.UUID
- token int64
- err error
-}
-type retryState struct {
- attempt int
- next time.Time
-}
-
-func (m *Manager) Run(ctx context.Context) error {
- active := make(map[uuid.UUID]*activeRuntime)
- retries := make(map[uuid.UUID]retryState)
- results := make(chan runtimeResult, 64)
- ticker := time.NewTicker(m.cfg.ReconcileInterval)
- defer ticker.Stop()
- m.ready.Store(true)
- defer m.ready.Store(false)
- if err := m.reconcile(ctx, active, retries, results); err != nil {
- m.logger.Warn().Err(err).Str("operation", "channel.reconcile").Msg("initial channel reconcile failed")
- }
- for {
- select {
- case <-ctx.Done():
- m.stopAll(active)
- return nil
- case result := <-results:
- m.handleResult(active, retries, result)
- case <-ticker.C:
- if err := m.reconcile(ctx, active, retries, results); err != nil {
- m.logger.Warn().Err(err).Str("operation", "channel.reconcile").Msg("channel reconcile failed")
- }
- }
- }
-}
-
-func (m *Manager) reconcile(ctx context.Context, active map[uuid.UUID]*activeRuntime, retries map[uuid.UUID]retryState, results chan<- runtimeResult) error {
- instances, err := m.store.ListEnabledChannels(ctx)
- if err != nil {
- return err
- }
- desired := make(map[uuid.UUID]model.ChannelInstance, len(instances))
- for _, instance := range instances {
- desired[instance.ID] = instance
- }
- for id, running := range active {
- instance, wanted := desired[id]
- if !wanted || instance.ConfigVersion != running.instance.ConfigVersion {
- m.stop(running)
- delete(active, id)
- delete(retries, id)
- continue
- }
- if err := m.store.RenewChannelLease(ctx, running.lease, m.clock().Add(m.cfg.LeaseDuration)); err != nil {
- m.stop(running)
- delete(active, id)
- }
- }
- for id := range retries {
- if _, wanted := desired[id]; !wanted {
- delete(retries, id)
- }
- }
- for id, instance := range desired {
- if active[id] != nil {
- continue
- }
- retry := retries[id]
- if m.clock().Before(retry.next) {
- continue
- }
- running, err := m.start(ctx, instance, retry.attempt, results)
- if err != nil {
- if !errors.Is(err, repository.ErrNotFound) {
- m.logger.Warn().Err(err).Str("operation", "channel.start").Str("channel_type", instance.Type).Msg("unable to start channel runtime")
- retries[id] = m.nextRetry(retry.attempt + 1)
- }
- continue
- }
- active[id] = running
- }
- return nil
-}
-
-func (m *Manager) start(parent context.Context, instance model.ChannelInstance, attempt int, results chan<- runtimeResult) (*activeRuntime, error) {
- lease, err := m.store.AcquireChannelLease(parent, instance.ID, m.owner, m.clock().Add(m.cfg.LeaseDuration))
- if err != nil {
- return nil, err
- }
- factory, ok := m.registry.Factory(instance.Type)
- if !ok {
- _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.unknown_type", "Unknown channel type")
- _ = m.store.ReleaseChannelLease(parent, lease)
- return nil, errors.New("unknown channel type")
- }
- secrets := map[string]string{}
- if instance.SecretRef != nil {
- ref := secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: instance.WorkspaceID, ResourceType: "channel_instance", ResourceID: instance.ID}}
- values, err := m.secrets.Get(parent, ref)
- if err != nil {
- _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.secret_failed", "Unable to decrypt channel credential")
- _ = m.store.ReleaseChannelLease(parent, lease)
- return nil, err
- }
- for key, value := range values {
- secrets[key] = value.Reveal()
- }
- }
- config := channelcore.InstanceConfig{AccountID: instance.ID.String(), Values: instance.Config.Values, Secrets: secrets, SenderAllowList: instance.Config.SenderAllowList, GroupPolicy: instance.Config.GroupPolicy}
- if err := factory.Validate(config); err != nil {
- _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.invalid_config", "Channel configuration is invalid")
- _ = m.store.ReleaseChannelLease(parent, lease)
- return nil, err
- }
- sink := &runtimeSink{store: m.store, instance: instance, observer: m.observer, tracer: m.tracer}
- sink.accepting.Store(true)
- runtime, err := factory.New(config, sink, channelcore.WithInboundMiddleware(recoverInbound))
- if err != nil {
- sink.accepting.Store(false)
- _ = m.setStatus(instance.ID, lease.FencingToken, "misconfigured", "channel.create_failed", "Unable to create channel runtime")
- _ = m.store.ReleaseChannelLease(parent, lease)
- return nil, err
- }
- ctx, cancel := context.WithCancel(parent)
- running := &activeRuntime{instance: instance, lease: lease, cancel: cancel, sink: sink, done: make(chan struct{}), started: m.clock(), attempt: attempt}
- now := m.clock().UTC()
- _ = m.store.SetChannelStatus(parent, model.ChannelRuntimeStatus{ChannelInstanceID: instance.ID, State: "running", BackendInstanceID: m.owner, FencingToken: lease.FencingToken, LastConnectedAt: &now})
- if m.observer != nil {
- m.observer.AddChannelRuntime(instance.Type, "running", 1)
- m.observer.ObserveChannelReconnect(instance.Type, "connected")
- }
- go func() {
- defer close(running.done)
- err := runRuntime(ctx, runtime)
- select {
- case results <- runtimeResult{id: instance.ID, token: lease.FencingToken, err: err}:
- return
- case <-parent.Done():
- return
- }
- }()
- return running, nil
-}
-
-func (m *Manager) handleResult(active map[uuid.UUID]*activeRuntime, retries map[uuid.UUID]retryState, result runtimeResult) {
- running := active[result.id]
- if running == nil || running.lease.FencingToken != result.token {
- return
- }
- running.sink.accepting.Store(false)
- running.sink.wait(m.cfg.DrainTimeout)
- delete(active, result.id)
- attempt := running.attempt + 1
- if m.clock().Sub(running.started) >= 2*m.cfg.LeaseDuration {
- attempt = 1
- }
- retries[result.id] = m.nextRetry(attempt)
- if m.observer != nil {
- m.observer.AddChannelRuntime(running.instance.Type, "running", -1)
- if result.err != nil {
- m.observer.ObserveChannelReconnect(running.instance.Type, "failed")
- } else {
- m.observer.ObserveChannelReconnect(running.instance.Type, "disconnected")
- }
- }
- if result.err != nil {
- _ = m.setStatus(result.id, result.token, "retry_wait", "channel.runtime_failed", safeMessage(result.err))
- } else {
- _ = m.setStatus(result.id, result.token, "retry_wait", "channel.disconnected", "Channel runtime disconnected")
- }
- _ = m.store.ReleaseChannelLease(context.Background(), running.lease)
-}
-
-func (m *Manager) nextRetry(attempt int) retryState {
- if attempt < 1 {
- attempt = 1
- }
- delay := m.cfg.RetryBaseDelay * time.Duration(1< m.cfg.RetryMaxDelay {
- delay = m.cfg.RetryMaxDelay
- }
- delay = m.jitter(delay)
- if delay < 0 {
- delay = 0
- }
- if delay > m.cfg.RetryMaxDelay {
- delay = m.cfg.RetryMaxDelay
- }
- return retryState{attempt: attempt, next: m.clock().Add(delay)}
-}
-func (m *Manager) stop(running *activeRuntime) {
- running.sink.accepting.Store(false)
- running.cancel()
- running.sink.wait(m.cfg.DrainTimeout)
- select {
- case <-running.done:
- case <-time.After(m.cfg.DrainTimeout):
- }
- _ = m.store.ReleaseChannelLease(context.Background(), running.lease)
- if m.observer != nil {
- m.observer.AddChannelRuntime(running.instance.Type, "running", -1)
- }
-}
-func (m *Manager) stopAll(active map[uuid.UUID]*activeRuntime) {
- for _, running := range active {
- m.stop(running)
- }
-}
-func (m *Manager) setStatus(id uuid.UUID, token int64, state, code, message string) error {
- return m.store.SetChannelStatus(context.Background(), model.ChannelRuntimeStatus{ChannelInstanceID: id, State: state, BackendInstanceID: m.owner, FencingToken: token, LastErrorCode: code, LastErrorMessage: message})
-}
-func runRuntime(ctx context.Context, runtime channelcore.Channel) (err error) {
- defer func() {
- if recovered := recover(); recovered != nil {
- err = fmt.Errorf("channel runtime panic (%T)\n%s", recovered, debug.Stack())
- }
- }()
- return runtime.Run(ctx)
-}
-func recoverInbound(next channelcore.InboundHandler) channelcore.InboundHandler {
- return func(ctx context.Context, message channelcore.InboundMessage) (err error) {
- defer func() {
- if recovered := recover(); recovered != nil {
- err = fmt.Errorf("channel message panic (%T)\n%s", recovered, debug.Stack())
- }
- }()
- return next(ctx, message)
- }
-}
-func safeMessage(err error) string {
- if err == nil {
- return ""
- }
- return sanitize.ErrorMessage(err, 2048)
-}
-
-type runtimeSink struct {
- store repository.ChannelStore
- instance model.ChannelInstance
- accepting atomic.Bool
- calls sync.WaitGroup
- observer Observer
- tracer trace.Tracer
-}
-
-func (s *runtimeSink) Accept(ctx context.Context, message channelcore.InboundMessage) (channelcore.AcceptResult, error) {
- ctx, span := s.tracer.Start(ctx, "channel.receive", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithAttributes(
- attribute.String("channel.type", boundedChannelType(s.instance.Type)),
- attribute.String("messaging.conversation.type", boundedConversationType(message.Conversation.Type)),
- ))
- defer span.End()
- if !s.accepting.Load() {
- s.observe("rejected")
- span.SetAttributes(attribute.String("mooncode.result", "rejected"))
- return "", context.Canceled
- }
- s.calls.Add(1)
- defer s.calls.Done()
- if !s.accepting.Load() {
- s.observe("rejected")
- span.SetAttributes(attribute.String("mooncode.result", "rejected"))
- return "", context.Canceled
- }
- inserted, err := s.store.AcceptInbound(ctx, s.instance, message)
- if err != nil {
- s.observe("failed")
- span.RecordError(err)
- span.SetStatus(codes.Error, "message persistence failed")
- span.SetAttributes(attribute.String("mooncode.result", "failed"))
- return "", err
- }
- if !inserted {
- s.observe("duplicate")
- span.SetAttributes(attribute.String("mooncode.result", "duplicate"))
- return channelcore.AlreadyAccepted, nil
- }
- s.observe("accepted")
- span.SetAttributes(attribute.String("mooncode.result", "accepted"))
- return channelcore.Accepted, nil
-}
-
-func boundedChannelType(value string) string {
- if value == "feishu" || value == "dingtalk" {
- return value
- }
- return "other"
-}
-
-func boundedConversationType(value string) string {
- if value == "direct" || value == "group" {
- return value
- }
- return "other"
-}
-func (s *runtimeSink) observe(result string) {
- if s.observer != nil {
- s.observer.ObserveChannelMessage(s.instance.Type, result)
- }
-}
-func (s *runtimeSink) wait(timeout time.Duration) {
- done := make(chan struct{})
- go func() { s.calls.Wait(); close(done) }()
- select {
- case <-done:
- case <-time.After(timeout):
- }
-}
-
-var _ channelcore.InboundSink = (*runtimeSink)(nil)
diff --git a/internal/channel/manager_test.go b/internal/channel/manager_test.go
deleted file mode 100644
index 32b771d..0000000
--- a/internal/channel/manager_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package channel
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
- "github.com/stretchr/testify/require"
-)
-
-func TestManagerRetryBackoffCapsAndUsesJitter(t *testing.T) {
- now := time.Unix(100, 0)
- manager := &Manager{
- cfg: config.ChannelsConfig{RetryBaseDelay: time.Second, RetryMaxDelay: 8 * time.Second},
- clock: func() time.Time { return now },
- jitter: func(delay time.Duration) time.Duration { return delay + time.Second },
- }
- require.Equal(t, now.Add(2*time.Second), manager.nextRetry(1).next)
- require.Equal(t, now.Add(5*time.Second), manager.nextRetry(3).next)
- require.Equal(t, now.Add(8*time.Second), manager.nextRetry(20).next)
-}
-
-func TestRuntimePanicIsContained(t *testing.T) {
- err := runRuntime(context.Background(), panicChannel{})
- require.ErrorContains(t, err, "channel runtime panic")
-}
-
-func TestInboundPanicIsContained(t *testing.T) {
- handler := recoverInbound(func(context.Context, channelcore.InboundMessage) error { panic("boom") })
- err := handler(t.Context(), channelcore.InboundMessage{})
- require.ErrorContains(t, err, "channel message panic (string)")
-}
-
-type panicChannel struct{}
-
-func (panicChannel) Type() string { return "fake" }
-func (panicChannel) Run(context.Context) error { panic("boom") }
-
-var _ channelcore.Channel = panicChannel{}
diff --git a/internal/channel/runtime/manager.go b/internal/channel/runtime/manager.go
new file mode 100644
index 0000000..11f0e5a
--- /dev/null
+++ b/internal/channel/runtime/manager.go
@@ -0,0 +1,234 @@
+package runtime
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+)
+
+const defaultPollInterval = 5 * time.Second
+
+type Store interface {
+ ListRuntimeChannels(context.Context) ([]Configuration, error)
+ SaveInbound(context.Context, Configuration, channel.InboundMessage) (bool, error)
+ MarkRuntimeConnected(context.Context, uuid.UUID, int64) error
+ MarkRuntimeError(context.Context, uuid.UUID, int64, string) error
+}
+
+type CommandHandler interface {
+ Handle(context.Context, uuid.UUID, uuid.UUID, int64, channel.InboundMessage) (string, error)
+}
+
+type Decryptor interface {
+ Decrypt([]byte, []byte, int) ([]byte, error)
+}
+
+type Manager struct {
+ store Store
+ decryptor Decryptor
+ factories map[string]channel.Factory
+ pollInterval time.Duration
+ logger *slog.Logger
+ commands CommandHandler
+ active map[uuid.UUID]activeConnection
+}
+
+type activeConnection struct {
+ version int64
+ connection channel.Connection
+}
+
+type startResult struct {
+ configuration Configuration
+ connection channel.Connection
+ err error
+}
+
+func NewManager(store Store, decryptor Decryptor, factories []channel.Factory, options ...Option) *Manager {
+ registered := make(map[string]channel.Factory, len(factories))
+ for _, factory := range factories {
+ registered[factory.Type()] = factory
+ }
+ manager := &Manager{
+ store: store, decryptor: decryptor, factories: registered,
+ pollInterval: defaultPollInterval, logger: slog.Default(), active: make(map[uuid.UUID]activeConnection),
+ }
+ for _, option := range options {
+ option(manager)
+ }
+
+ return manager
+}
+
+func (m *Manager) Run(ctx context.Context) error {
+ m.reconcileAndLog(ctx)
+ ticker := time.NewTicker(m.pollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ m.closeAll()
+ return nil
+ case <-ticker.C:
+ m.reconcileAndLog(ctx)
+ }
+ }
+}
+
+func (m *Manager) reconcileAndLog(ctx context.Context) {
+ if err := m.reconcile(ctx); err != nil && ctx.Err() == nil {
+ m.logger.ErrorContext(ctx, "channel runtime reconciliation failed", "error", err)
+ }
+}
+
+func (m *Manager) reconcile(ctx context.Context) error {
+ configurations, err := m.store.ListRuntimeChannels(ctx)
+ if err != nil {
+ return fmt.Errorf("list runtime channels: %w", err)
+ }
+ desired := make(map[uuid.UUID]Configuration, len(configurations))
+ for _, item := range configurations {
+ desired[item.ID] = item
+ }
+
+ for id, running := range m.active {
+ item, enabled := desired[id]
+ if enabled && item.Version == running.version {
+ continue
+ }
+ if err := running.connection.Close(); err != nil {
+ m.logger.WarnContext(ctx, "close channel connection", "channel_id", id, "error", err)
+ }
+ delete(m.active, id)
+ }
+
+ candidates := make([]Configuration, 0, len(configurations))
+ for _, item := range configurations {
+ if _, running := m.active[item.ID]; !running {
+ candidates = append(candidates, item)
+ }
+ }
+ results := make(chan startResult, len(candidates))
+ for _, item := range candidates {
+ go func(configuration Configuration) {
+ connection, startError := m.start(ctx, configuration)
+ results <- startResult{configuration: configuration, connection: connection, err: startError}
+ }(item)
+ }
+ for range candidates {
+ result := <-results
+ if result.err != nil {
+ m.recordError(ctx, result.configuration, result.err)
+ continue
+ }
+ if ctx.Err() != nil {
+ _ = result.connection.Close()
+ continue
+ }
+ m.active[result.configuration.ID] = activeConnection{version: result.configuration.Version, connection: result.connection}
+ }
+
+ return nil
+}
+
+func (m *Manager) start(ctx context.Context, item Configuration) (channel.Connection, error) {
+ factory, ok := m.factories[item.Type]
+ if !ok {
+ return nil, fmt.Errorf("channel type %q is not registered", item.Type)
+ }
+ plaintext, err := m.decryptor.Decrypt(item.Ciphertext, item.Nonce, item.KeyVersion)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt channel credential: %w", err)
+ }
+ defer clear(plaintext)
+
+ var credentials map[string]string
+ if err := json.Unmarshal(plaintext, &credentials); err != nil {
+ return nil, fmt.Errorf("decode channel credential: %w", err)
+ }
+ var config configuration
+ if err := json.Unmarshal(item.Config, &config); err != nil {
+ return nil, fmt.Errorf("decode channel config: %w", err)
+ }
+ secretKey := "app_secret"
+ if item.Type == "dingtalk" {
+ secretKey = "client_secret"
+ }
+ connection, err := factory.New(config.Values, credentials[secretKey])
+ if err != nil {
+ return nil, err
+ }
+ handler := func(messageContext context.Context, message channel.InboundMessage) error {
+ normalized, accepted := applyPolicy(config, message)
+ if !accepted {
+ return nil
+ }
+ inserted, err := m.store.SaveInbound(messageContext, item, normalized)
+ if err != nil || !inserted || m.commands == nil {
+ return err
+ }
+ response, commandErr := m.commands.Handle(messageContext, item.WorkspaceID, item.ID, item.Version, normalized)
+ if commandErr != nil {
+ m.logger.ErrorContext(messageContext, "handle channel command", "channel_id", item.ID, "message_id", normalized.ExternalID, "error", commandErr)
+ response = "MoonCode could not complete that command. Please try again later."
+ }
+ if strings.TrimSpace(response) == "" {
+ return nil
+ }
+ targetID := normalized.ConversationID
+ if normalized.ConversationType == "direct" && item.Type == "dingtalk" {
+ targetID = rawSenderID(normalized.SenderCanonicalID)
+ }
+ if err := connection.Send(messageContext, channel.Message{Text: response, TargetID: targetID, TargetType: normalized.ConversationType}); err != nil {
+ m.logger.ErrorContext(messageContext, "send channel command response", "channel_id", item.ID, "message_id", normalized.ExternalID, "error", err)
+ }
+
+ return nil
+ }
+ if err := connection.Start(ctx, handler); err != nil {
+ _ = connection.Close()
+ return nil, err
+ }
+ if err := m.store.MarkRuntimeConnected(ctx, item.ID, item.Version); err != nil {
+ _ = connection.Close()
+ return nil, fmt.Errorf("mark channel connected: %w", err)
+ }
+
+ return connection, nil
+}
+
+func rawSenderID(canonical string) string {
+ _, value, found := strings.Cut(canonical, ":")
+ if found {
+ return value
+ }
+
+ return canonical
+}
+
+func (m *Manager) recordError(ctx context.Context, item Configuration, cause error) {
+ message := strings.TrimSpace(cause.Error())
+ if len(message) > 1000 {
+ message = message[:1000]
+ }
+ if err := m.store.MarkRuntimeError(ctx, item.ID, item.Version, message); err != nil {
+ m.logger.ErrorContext(ctx, "record channel runtime error", "channel_id", item.ID, "error", err)
+ }
+ m.logger.WarnContext(ctx, "channel connection unavailable", "channel_id", item.ID, "channel_type", item.Type, "error", cause)
+}
+
+func (m *Manager) closeAll() {
+ for id, running := range m.active {
+ if err := running.connection.Close(); err != nil {
+ m.logger.Warn("close channel connection", "channel_id", id, "error", err)
+ }
+ delete(m.active, id)
+ }
+}
diff --git a/internal/channel/runtime/manager_test.go b/internal/channel/runtime/manager_test.go
new file mode 100644
index 0000000..9a22d82
--- /dev/null
+++ b/internal/channel/runtime/manager_test.go
@@ -0,0 +1,205 @@
+package runtime
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+)
+
+type runtimeStore struct {
+ mu sync.Mutex
+ configurations []Configuration
+ connected []int64
+ errors []string
+ messages []channel.InboundMessage
+ seen map[string]struct{}
+}
+
+func (s *runtimeStore) ListRuntimeChannels(context.Context) ([]Configuration, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ return append([]Configuration(nil), s.configurations...), nil
+}
+
+func (s *runtimeStore) SaveInbound(_ context.Context, _ Configuration, message channel.InboundMessage) (bool, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.seen == nil {
+ s.seen = make(map[string]struct{})
+ }
+ if _, duplicate := s.seen[message.ExternalID]; duplicate {
+ return false, nil
+ }
+ s.seen[message.ExternalID] = struct{}{}
+ s.messages = append(s.messages, message)
+
+ return true, nil
+}
+
+func (s *runtimeStore) MarkRuntimeConnected(_ context.Context, _ uuid.UUID, version int64) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.connected = append(s.connected, version)
+
+ return nil
+}
+
+func (s *runtimeStore) MarkRuntimeError(_ context.Context, _ uuid.UUID, _ int64, message string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.errors = append(s.errors, message)
+
+ return nil
+}
+
+type runtimeDecryptor struct{ plaintext []byte }
+
+func (d runtimeDecryptor) Decrypt([]byte, []byte, int) ([]byte, error) {
+ return append([]byte(nil), d.plaintext...), nil
+}
+
+type runtimeFactory struct {
+ mu sync.Mutex
+ connections []*runtimeConnection
+ messages []channel.InboundMessage
+ startError error
+}
+
+func (*runtimeFactory) Type() string { return "feishu" }
+
+func (f *runtimeFactory) New(map[string]any, string) (channel.Connection, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ connection := &runtimeConnection{messages: append([]channel.InboundMessage(nil), f.messages...), startError: f.startError}
+ f.connections = append(f.connections, connection)
+
+ return connection, nil
+}
+
+type runtimeConnection struct {
+ messages []channel.InboundMessage
+ sent []channel.Message
+ startError error
+ closed bool
+}
+
+func (c *runtimeConnection) Send(_ context.Context, message channel.Message) error {
+ c.sent = append(c.sent, message)
+
+ return nil
+}
+
+func (c *runtimeConnection) Start(ctx context.Context, handler channel.Handler) error {
+ if c.startError != nil {
+ return c.startError
+ }
+ for _, message := range c.messages {
+ if err := handler(ctx, message); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (c *runtimeConnection) Close() error {
+ c.closed = true
+ return nil
+}
+
+func TestManagerReconcilesLifecycleAndInboundIdempotency(t *testing.T) {
+ channelID := uuid.New()
+ store := &runtimeStore{configurations: []Configuration{{
+ ID: channelID, WorkspaceID: uuid.New(), Type: "feishu", Version: 1,
+ Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["allowed"],"groupPolicy":{"requireMention":true,"prefix":"/moon"}}`),
+ }}}
+ factory := &runtimeFactory{messages: []channel.InboundMessage{
+ {ExternalID: "accepted", SenderCanonicalID: "feishu:allowed", ConversationType: "group", Text: "/moon analyze", OccurredAt: time.Now()},
+ {ExternalID: "denied", SenderCanonicalID: "feishu:other", ConversationType: "direct", Text: "analyze", OccurredAt: time.Now()},
+ {ExternalID: "accepted", SenderCanonicalID: "feishu:allowed", ConversationType: "group", Text: "/moon analyze", OccurredAt: time.Now()},
+ }}
+ manager := NewManager(store, runtimeDecryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, []channel.Factory{factory})
+
+ if err := manager.reconcile(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.connected) != 1 || len(store.messages) != 1 || store.messages[0].Text != "analyze" {
+ t.Fatalf("unexpected first reconciliation: connected=%v messages=%+v", store.connected, store.messages)
+ }
+ first := factory.connections[0]
+ store.configurations[0].Version = 2
+ if err := manager.reconcile(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if !first.closed || len(factory.connections) != 2 || len(store.connected) != 2 {
+ t.Fatalf("expected version change to replace connection: closed=%v connections=%d connected=%v", first.closed, len(factory.connections), store.connected)
+ }
+
+ store.configurations = nil
+ if err := manager.reconcile(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if !factory.connections[1].closed || len(manager.active) != 0 {
+ t.Fatal("expected disabled channel to be closed")
+ }
+}
+
+func TestManagerIsolatesConnectionFailure(t *testing.T) {
+ failingFactory := &runtimeFactory{startError: errors.New("handshake failed")}
+ store := &runtimeStore{configurations: []Configuration{{
+ ID: uuid.New(), WorkspaceID: uuid.New(), Type: "feishu", Version: 1,
+ Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`),
+ }}}
+ manager := NewManager(store, runtimeDecryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, []channel.Factory{failingFactory})
+
+ if err := manager.reconcile(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.errors) != 1 || len(manager.active) != 0 {
+ t.Fatalf("expected isolated failure, errors=%v active=%d", store.errors, len(manager.active))
+ }
+}
+
+type runtimeCommands struct {
+ workspaceID uuid.UUID
+ channelID uuid.UUID
+ message channel.InboundMessage
+}
+
+func (c *runtimeCommands) Handle(_ context.Context, workspaceID, channelID uuid.UUID, _ int64, message channel.InboundMessage) (string, error) {
+ c.workspaceID, c.channelID, c.message = workspaceID, channelID, message
+
+ return "analysis queued", nil
+}
+
+func TestManagerHandlesInsertedMessageAndRepliesToConversation(t *testing.T) {
+ channelID, workspaceID := uuid.New(), uuid.New()
+ store := &runtimeStore{configurations: []Configuration{{
+ ID: channelID, WorkspaceID: workspaceID, Type: "feishu", Version: 1,
+ Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"},"senderAllowList":["*"]}`),
+ }}}
+ factory := &runtimeFactory{messages: []channel.InboundMessage{{
+ ExternalID: "command", ConversationID: "chat-42", ConversationType: "group",
+ SenderCanonicalID: "feishu:sender", Text: "analyze", OccurredAt: time.Now(),
+ }}}
+ commands := &runtimeCommands{}
+ manager := NewManager(store, runtimeDecryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, []channel.Factory{factory}, WithCommandHandler(commands))
+
+ if err := manager.reconcile(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ connection := factory.connections[0]
+ if commands.workspaceID != workspaceID || commands.channelID != channelID || commands.message.ExternalID != "command" {
+ t.Fatalf("command handler received unexpected context: %+v", commands)
+ }
+ if len(connection.sent) != 1 || connection.sent[0].Text != "analysis queued" || connection.sent[0].TargetID != "chat-42" || connection.sent[0].TargetType != "group" {
+ t.Fatalf("unexpected command reply: %+v", connection.sent)
+ }
+}
diff --git a/internal/channel/runtime/model.go b/internal/channel/runtime/model.go
new file mode 100644
index 0000000..c855dea
--- /dev/null
+++ b/internal/channel/runtime/model.go
@@ -0,0 +1,29 @@
+package runtime
+
+import (
+ "encoding/json"
+
+ "github.com/google/uuid"
+)
+
+type Configuration struct {
+ ID uuid.UUID
+ WorkspaceID uuid.UUID
+ Type string
+ Version int64
+ Ciphertext []byte
+ Nonce []byte
+ KeyVersion int
+ Config json.RawMessage
+}
+
+type configuration struct {
+ Values map[string]any `json:"values"`
+ SenderAllowList []string `json:"senderAllowList"`
+ GroupPolicy groupPolicy `json:"groupPolicy"`
+}
+
+type groupPolicy struct {
+ RequireMention bool `json:"requireMention"`
+ Prefix string `json:"prefix"`
+}
diff --git a/internal/channel/runtime/options.go b/internal/channel/runtime/options.go
new file mode 100644
index 0000000..af749fe
--- /dev/null
+++ b/internal/channel/runtime/options.go
@@ -0,0 +1,28 @@
+package runtime
+
+import (
+ "log/slog"
+ "time"
+)
+
+type Option func(*Manager)
+
+func WithPollInterval(interval time.Duration) Option {
+ return func(manager *Manager) {
+ if interval > 0 {
+ manager.pollInterval = interval
+ }
+ }
+}
+
+func WithLogger(logger *slog.Logger) Option {
+ return func(manager *Manager) {
+ if logger != nil {
+ manager.logger = logger
+ }
+ }
+}
+
+func WithCommandHandler(handler CommandHandler) Option {
+ return func(manager *Manager) { manager.commands = handler }
+}
diff --git a/internal/channel/runtime/policy.go b/internal/channel/runtime/policy.go
new file mode 100644
index 0000000..9c13801
--- /dev/null
+++ b/internal/channel/runtime/policy.go
@@ -0,0 +1,47 @@
+package runtime
+
+import (
+ "strings"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+)
+
+func applyPolicy(config configuration, message channel.InboundMessage) (channel.InboundMessage, bool) {
+ if !senderAllowed(config.SenderAllowList, message.SenderCanonicalID) {
+ return channel.InboundMessage{}, false
+ }
+
+ message.Text = strings.TrimSpace(message.Text)
+ if message.ConversationType != "group" {
+ return message, message.Text != ""
+ }
+ if message.Mentioned {
+ return message, message.Text != ""
+ }
+
+ prefix := strings.TrimSpace(config.GroupPolicy.Prefix)
+ if prefix != "" && strings.HasPrefix(message.Text, prefix) {
+ message.Text = strings.TrimSpace(strings.TrimPrefix(message.Text, prefix))
+ return message, message.Text != ""
+ }
+ if config.GroupPolicy.RequireMention {
+ return channel.InboundMessage{}, false
+ }
+
+ return message, message.Text != ""
+}
+
+func senderAllowed(allowlist []string, canonicalID string) bool {
+ rawID := canonicalID
+ if separator := strings.IndexByte(canonicalID, ':'); separator >= 0 {
+ rawID = canonicalID[separator+1:]
+ }
+ for _, allowed := range allowlist {
+ allowed = strings.TrimSpace(allowed)
+ if allowed == "*" || allowed == canonicalID || allowed == rawID {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/channel/runtime/policy_test.go b/internal/channel/runtime/policy_test.go
new file mode 100644
index 0000000..0cb458f
--- /dev/null
+++ b/internal/channel/runtime/policy_test.go
@@ -0,0 +1,43 @@
+package runtime
+
+import (
+ "testing"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+)
+
+func TestApplyPolicy(t *testing.T) {
+ config := configuration{
+ SenderAllowList: []string{"ou_allowed"},
+ GroupPolicy: groupPolicy{RequireMention: true, Prefix: "/moon"},
+ }
+ tests := []struct {
+ name string
+ message channel.InboundMessage
+ want string
+ accept bool
+ }{
+ {name: "allowed direct", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "direct", Text: " hello "}, want: "hello", accept: true},
+ {name: "denied sender", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_denied", ConversationType: "direct", Text: "hello"}},
+ {name: "mentioned group", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "group", Text: "analyze", Mentioned: true}, want: "analyze", accept: true},
+ {name: "prefixed group", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "group", Text: "/moon analyze"}, want: "analyze", accept: true},
+ {name: "ordinary group", message: channel.InboundMessage{SenderCanonicalID: "feishu:ou_allowed", ConversationType: "group", Text: "hello"}},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ message, accepted := applyPolicy(config, test.message)
+ if accepted != test.accept || message.Text != test.want {
+ t.Fatalf("applyPolicy() = (%q, %v), want (%q, %v)", message.Text, accepted, test.want, test.accept)
+ }
+ })
+ }
+}
+
+func TestApplyPolicyExplicitWildcard(t *testing.T) {
+ config := configuration{SenderAllowList: []string{"*"}}
+ message, accepted := applyPolicy(config, channel.InboundMessage{SenderCanonicalID: "dingtalk:anyone", ConversationType: "group", Text: "hello"})
+ if !accepted || message.Text != "hello" {
+ t.Fatalf("unexpected wildcard result: %+v, accepted=%v", message, accepted)
+ }
+}
diff --git a/internal/channel/workflow/model.go b/internal/channel/workflow/model.go
new file mode 100644
index 0000000..45dcd3a
--- /dev/null
+++ b/internal/channel/workflow/model.go
@@ -0,0 +1,28 @@
+package workflow
+
+import (
+ "encoding/json"
+
+ "github.com/google/uuid"
+)
+
+type WorkItem struct {
+ NotificationID uuid.UUID
+ Status string
+ EventType string
+ WorkspaceID uuid.UUID
+ AnalysisRunID uuid.UUID
+ ChannelID uuid.UUID
+ ChannelType string
+ ChannelName string
+ ChannelEnabled bool
+ Ciphertext []byte
+ Nonce []byte
+ KeyVersion int
+ Config json.RawMessage
+ RepositoryName string
+ CommitSHA string
+ FailedStage string
+ ErrorMessage string
+ Result json.RawMessage
+}
diff --git a/internal/channel/workflow/runner.go b/internal/channel/workflow/runner.go
new file mode 100644
index 0000000..9de49dc
--- /dev/null
+++ b/internal/channel/workflow/runner.go
@@ -0,0 +1,161 @@
+package workflow
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+
+ channelbiz "github.com/fuchencong/mooncode/internal/channel/biz"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type Store interface {
+ LoadDelivery(context.Context, uuid.UUID) (WorkItem, error)
+ StartDelivery(context.Context, uuid.UUID) error
+ FinishDelivery(context.Context, uuid.UUID) error
+ CancelDelivery(context.Context, uuid.UUID) error
+ FailDelivery(context.Context, uuid.UUID, string) error
+}
+
+type Decryptor interface {
+ Decrypt([]byte, []byte, int) ([]byte, error)
+}
+
+type Runner struct {
+ store Store
+ decryptor Decryptor
+ factories map[string]channelpkg.Factory
+}
+
+func NewRunner(store Store, decryptor Decryptor, factories ...channelpkg.Factory) *Runner {
+ registered := make(map[string]channelpkg.Factory, len(factories))
+ for _, factory := range factories {
+ registered[factory.Type()] = factory
+ }
+
+ return &Runner{store: store, decryptor: decryptor, factories: registered}
+}
+
+func (r *Runner) Execute(ctx context.Context, notificationID uuid.UUID) error {
+ work, err := r.store.LoadDelivery(ctx, notificationID)
+ if err != nil {
+ return err
+ }
+ if work.Status == "queued" {
+ if err = r.store.StartDelivery(ctx, notificationID); err != nil {
+ return err
+ }
+ } else if work.Status != "running" {
+ return nil
+ }
+ if !work.ChannelEnabled {
+ return r.store.CancelDelivery(ctx, notificationID)
+ }
+
+ factory, ok := r.factories[work.ChannelType]
+ if !ok {
+ return workflowbiz.Permanent(fmt.Errorf("channel type %q is not registered", work.ChannelType))
+ }
+ plaintext, err := r.decryptor.Decrypt(work.Ciphertext, work.Nonce, work.KeyVersion)
+ if err != nil {
+ return workflowbiz.Permanent(fmt.Errorf("decrypt channel credential: %w", err))
+ }
+ defer clear(plaintext)
+ var credentials map[string]string
+ if err := json.Unmarshal(plaintext, &credentials); err != nil {
+ return workflowbiz.Permanent(fmt.Errorf("decode channel credential: %w", err))
+ }
+ var config struct {
+ Values map[string]any `json:"values"`
+ }
+ if err := json.Unmarshal(work.Config, &config); err != nil {
+ return workflowbiz.Permanent(fmt.Errorf("decode channel config: %w", err))
+ }
+ secretKey := "app_secret"
+ if work.ChannelType == "dingtalk" {
+ secretKey = "client_secret"
+ }
+ sender, err := factory.New(config.Values, credentials[secretKey])
+ if err != nil {
+ return workflowbiz.Permanent(err)
+ }
+ defer func() { _ = sender.Close() }()
+ if err := sender.Send(ctx, channelpkg.Message{Text: notificationText(work)}); err != nil {
+ if channelpkg.IsPermanent(err) {
+ return workflowbiz.Permanent(err)
+ }
+
+ return err
+ }
+
+ return r.store.FinishDelivery(ctx, notificationID)
+}
+
+func (r *Runner) MarkFailed(ctx context.Context, notificationID uuid.UUID, cause error) error {
+ if _, err := r.store.LoadDelivery(ctx, notificationID); errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ message := "notification delivery failed"
+ if cause != nil {
+ message = cause.Error()
+ }
+ if len(message) > 1000 {
+ message = message[:1000]
+ }
+
+ return r.store.FailDelivery(ctx, notificationID, message)
+}
+
+func notificationText(work WorkItem) string {
+ switch work.EventType {
+ case channelbiz.EventAnalysisStarted:
+ return fmt.Sprintf(
+ "MoonCode analysis started\nRepository: %s\nCommit: %.12s",
+ work.RepositoryName,
+ work.CommitSHA,
+ )
+ case channelbiz.EventAnalysisFailed:
+ return fmt.Sprintf(
+ "MoonCode analysis failed\nRepository: %s\nCommit: %.12s\nStage: %s\nError: %s",
+ work.RepositoryName,
+ work.CommitSHA,
+ work.FailedStage,
+ work.ErrorMessage,
+ )
+ }
+
+ var result struct {
+ Summary struct {
+ Files int64 `json:"files"`
+ Code int64 `json:"code"`
+ Comments int64 `json:"comments"`
+ Complexity int64 `json:"complexity"`
+ } `json:"summary"`
+ Languages []struct {
+ Name string `json:"name"`
+ } `json:"languages"`
+ }
+ _ = json.Unmarshal(work.Result, &result)
+ languages := make([]string, 0, min(5, len(result.Languages)))
+ for _, language := range result.Languages[:min(5, len(result.Languages))] {
+ languages = append(languages, language.Name)
+ }
+
+ return fmt.Sprintf(
+ "MoonCode analysis completed\nRepository: %s\nCommit: %.12s\nFiles: %d · Code: %d · Comments: %d · Complexity: %d\nLanguages: %s",
+ work.RepositoryName,
+ work.CommitSHA,
+ result.Summary.Files,
+ result.Summary.Code,
+ result.Summary.Comments,
+ result.Summary.Complexity,
+ strings.Join(languages, ", "),
+ )
+}
diff --git a/internal/channel/workflow/runner_test.go b/internal/channel/workflow/runner_test.go
new file mode 100644
index 0000000..88b2dea
--- /dev/null
+++ b/internal/channel/workflow/runner_test.go
@@ -0,0 +1,140 @@
+package workflow
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/google/uuid"
+)
+
+type deliveryStore struct {
+ work WorkItem
+ started bool
+ finished bool
+ cancelled bool
+ failed string
+}
+
+func (s *deliveryStore) LoadDelivery(context.Context, uuid.UUID) (WorkItem, error) {
+ return s.work, nil
+}
+func (s *deliveryStore) StartDelivery(context.Context, uuid.UUID) error {
+ s.started = true
+ return nil
+}
+func (s *deliveryStore) FinishDelivery(context.Context, uuid.UUID) error {
+ s.finished = true
+ return nil
+}
+func (s *deliveryStore) CancelDelivery(context.Context, uuid.UUID) error {
+ s.cancelled = true
+ return nil
+}
+func (s *deliveryStore) FailDelivery(_ context.Context, _ uuid.UUID, message string) error {
+ s.failed = message
+ return nil
+}
+
+type decryptor struct{ plaintext []byte }
+
+func (d decryptor) Decrypt([]byte, []byte, int) ([]byte, error) { return d.plaintext, nil }
+
+type senderFactory struct{ sender *messageSender }
+
+func (senderFactory) Type() string { return "feishu" }
+func (f senderFactory) New(config map[string]any, secret string) (channelpkg.Connection, error) {
+ if config["app_id"] != "app" || secret != "secret" {
+ panic("unexpected channel configuration")
+ }
+ return f.sender, nil
+}
+
+type messageSender struct{ text string }
+
+func (s *messageSender) Send(_ context.Context, message channelpkg.Message) error {
+ s.text = message.Text
+ return nil
+}
+func (*messageSender) Start(context.Context, channelpkg.Handler) error { return nil }
+func (*messageSender) Close() error { return nil }
+
+func TestRunnerDeliversAnalysisSummary(t *testing.T) {
+ notificationID := uuid.New()
+ store := &deliveryStore{work: WorkItem{
+ NotificationID: notificationID,
+ Status: "queued",
+ EventType: "analysis.succeeded",
+ ChannelType: "feishu",
+ ChannelEnabled: true,
+ Config: json.RawMessage(`{"values":{"app_id":"app","receive_id":"chat"}}`),
+ RepositoryName: "mooncode",
+ CommitSHA: "1234567890abcdef",
+ Result: json.RawMessage(`{"summary":{"files":12,"code":345,"comments":20,"complexity":7},"languages":[{"name":"Go"}]}`),
+ }}
+ sender := &messageSender{}
+ runner := NewRunner(store, decryptor{plaintext: []byte(`{"app_secret":"secret"}`)}, senderFactory{sender: sender})
+
+ if err := runner.Execute(context.Background(), notificationID); err != nil {
+ t.Fatal(err)
+ }
+ if !store.started || !store.finished || store.cancelled {
+ t.Fatalf("unexpected delivery state: started=%v finished=%v cancelled=%v", store.started, store.finished, store.cancelled)
+ }
+ for _, expected := range []string{"mooncode", "1234567890ab", "Files: 12", "Code: 345", "Go"} {
+ if !strings.Contains(sender.text, expected) {
+ t.Fatalf("message %q does not contain %q", sender.text, expected)
+ }
+ }
+}
+
+func TestNotificationTextProjectsLifecycleEvents(t *testing.T) {
+ started := notificationText(WorkItem{EventType: "analysis.started", RepositoryName: "mooncode", CommitSHA: "1234567890abcdef"})
+ for _, expected := range []string{"analysis started", "mooncode", "1234567890ab"} {
+ if !strings.Contains(started, expected) {
+ t.Fatalf("started message %q does not contain %q", started, expected)
+ }
+ }
+
+ failed := notificationText(WorkItem{
+ EventType: "analysis.failed",
+ RepositoryName: "mooncode",
+ CommitSHA: "1234567890abcdef",
+ FailedStage: "analyze",
+ ErrorMessage: "Analyzer execution failed",
+ })
+ for _, expected := range []string{"analysis failed", "analyze", "Analyzer execution failed"} {
+ if !strings.Contains(failed, expected) {
+ t.Fatalf("failed message %q does not contain %q", failed, expected)
+ }
+ }
+}
+
+func TestRunnerCancelsDisabledChannel(t *testing.T) {
+ notificationID := uuid.New()
+ store := &deliveryStore{work: WorkItem{NotificationID: notificationID, Status: "queued"}}
+ runner := NewRunner(store, decryptor{})
+
+ if err := runner.Execute(context.Background(), notificationID); err != nil {
+ t.Fatal(err)
+ }
+ if !store.started || !store.cancelled || store.finished {
+ t.Fatalf("unexpected delivery state: started=%v finished=%v cancelled=%v", store.started, store.finished, store.cancelled)
+ }
+}
+
+func TestRunnerSkipsTerminalDelivery(t *testing.T) {
+ notificationID := uuid.New()
+ store := &deliveryStore{work: WorkItem{NotificationID: notificationID, Status: "delivered"}}
+ sender := &messageSender{}
+ runner := NewRunner(store, decryptor{}, senderFactory{sender: sender})
+
+ if err := runner.Execute(context.Background(), notificationID); err != nil {
+ t.Fatal(err)
+ }
+ if store.started || store.finished || store.cancelled || sender.text != "" {
+ t.Fatal("terminal notification delivery was executed again")
+ }
+}
diff --git a/internal/checkout/store.go b/internal/checkout/store.go
deleted file mode 100644
index 5805f6f..0000000
--- a/internal/checkout/store.go
+++ /dev/null
@@ -1,139 +0,0 @@
-// Package checkout manages the current source tree of a repository.
-package checkout
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sync"
-
- "github.com/google/uuid"
-)
-
-// Populate writes a complete repository checkout to directory. The directory
-// does not exist before Populate is called.
-type Populate func(ctx context.Context, directory string) error
-
-// Store keeps exactly one current checkout for each repository.
-type Store interface {
- Replace(ctx context.Context, repositoryID uuid.UUID, populate Populate) (string, error)
- Path(repositoryID uuid.UUID) string
- Remove(ctx context.Context, repositoryID uuid.UUID) error
-}
-
-type Option func(*FilesystemStore)
-
-// WithDirectoryMode changes the permissions used when creating store-owned
-// directories. The default is 0750.
-func WithDirectoryMode(mode os.FileMode) Option {
- return func(store *FilesystemStore) {
- if mode.Perm() != 0 {
- store.directoryMode = mode.Perm()
- }
- }
-}
-
-// FilesystemStore persists checkouts below one filesystem root. Replacement
-// uses same-filesystem renames so readers never observe a partially populated
-// checkout.
-type FilesystemStore struct {
- root string
- directoryMode os.FileMode
- mu sync.Mutex
-}
-
-func NewFilesystemStore(root string, opts ...Option) (*FilesystemStore, error) {
- if root == "" {
- return nil, errors.New("checkout root is required")
- }
- absolute, err := filepath.Abs(root)
- if err != nil {
- return nil, fmt.Errorf("resolve checkout root: %w", err)
- }
- store := &FilesystemStore{root: filepath.Clean(absolute), directoryMode: 0o750}
- for _, option := range opts {
- if option != nil {
- option(store)
- }
- }
- if err := os.RemoveAll(store.stagingRoot()); err != nil {
- return nil, fmt.Errorf("clean checkout staging directory: %w", err)
- }
- if err := os.MkdirAll(store.stagingRoot(), store.directoryMode); err != nil {
- return nil, fmt.Errorf("create checkout root: %w", err)
- }
- return store, nil
-}
-
-func (s *FilesystemStore) Replace(ctx context.Context, repositoryID uuid.UUID, populate Populate) (string, error) {
- if repositoryID == uuid.Nil {
- return "", errors.New("repository ID is required")
- }
- if populate == nil {
- return "", errors.New("checkout populate function is required")
- }
- if err := ctx.Err(); err != nil {
- return "", err
- }
- s.mu.Lock()
- defer s.mu.Unlock()
-
- staged := filepath.Join(s.stagingRoot(), repositoryID.String()+"-"+uuid.NewString())
- defer func() { _ = os.RemoveAll(staged) }()
- if err := populate(ctx, staged); err != nil {
- return "", err
- }
- if err := ctx.Err(); err != nil {
- return "", err
- }
-
- target := s.Path(repositoryID)
- previous := filepath.Join(s.stagingRoot(), repositoryID.String()+"-previous-"+uuid.NewString())
- hadPrevious := false
- if _, err := os.Stat(target); err == nil {
- if err := os.Rename(target, previous); err != nil {
- return "", fmt.Errorf("move previous checkout: %w", err)
- }
- hadPrevious = true
- } else if !errors.Is(err, os.ErrNotExist) {
- return "", fmt.Errorf("inspect current checkout: %w", err)
- }
-
- if err := os.Rename(staged, target); err != nil {
- if hadPrevious {
- _ = os.Rename(previous, target)
- }
- return "", fmt.Errorf("activate checkout: %w", err)
- }
- if hadPrevious {
- _ = os.RemoveAll(previous)
- }
- return target, nil
-}
-
-func (s *FilesystemStore) Path(repositoryID uuid.UUID) string {
- return filepath.Join(s.root, repositoryID.String())
-}
-
-func (s *FilesystemStore) Remove(ctx context.Context, repositoryID uuid.UUID) error {
- if repositoryID == uuid.Nil {
- return errors.New("repository ID is required")
- }
- if err := ctx.Err(); err != nil {
- return err
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- if err := os.RemoveAll(s.Path(repositoryID)); err != nil {
- return fmt.Errorf("remove repository checkout: %w", err)
- }
- return nil
-}
-
-func (s *FilesystemStore) stagingRoot() string {
- return filepath.Join(s.root, ".staging")
-}
-
-var _ Store = (*FilesystemStore)(nil)
diff --git a/internal/checkout/store_test.go b/internal/checkout/store_test.go
deleted file mode 100644
index a16f118..0000000
--- a/internal/checkout/store_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package checkout
-
-import (
- "context"
- "errors"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/google/uuid"
- "github.com/stretchr/testify/require"
-)
-
-func TestFilesystemStoreReplacesCheckout(t *testing.T) {
- store, err := NewFilesystemStore(t.TempDir())
- require.NoError(t, err)
- repositoryID := uuid.New()
-
- write := func(content string) Populate {
- return func(_ context.Context, directory string) error {
- require.NoError(t, os.MkdirAll(directory, 0o750))
- return os.WriteFile(filepath.Join(directory, "README.md"), []byte(content), 0o600)
- }
- }
-
- _, err = store.Replace(t.Context(), repositoryID, write("first"))
- require.NoError(t, err)
- path, err := store.Replace(t.Context(), repositoryID, write("second"))
- require.NoError(t, err)
- content, err := os.ReadFile(filepath.Join(path, "README.md"))
- require.NoError(t, err)
- require.Equal(t, "second", string(content))
-
- require.NoError(t, store.Remove(t.Context(), repositoryID))
- _, err = os.Stat(path)
- require.ErrorIs(t, err, os.ErrNotExist)
-}
-
-func TestFilesystemStoreKeepsCurrentCheckoutWhenPopulateFails(t *testing.T) {
- store, err := NewFilesystemStore(t.TempDir())
- require.NoError(t, err)
- repositoryID := uuid.New()
- _, err = store.Replace(t.Context(), repositoryID, func(_ context.Context, directory string) error {
- require.NoError(t, os.MkdirAll(directory, 0o750))
- return os.WriteFile(filepath.Join(directory, "current"), []byte("ok"), 0o600)
- })
- require.NoError(t, err)
-
- expected := errors.New("clone failed")
- _, err = store.Replace(t.Context(), repositoryID, func(context.Context, string) error { return expected })
- require.ErrorIs(t, err, expected)
- content, err := os.ReadFile(filepath.Join(store.Path(repositoryID), "current"))
- require.NoError(t, err)
- require.Equal(t, "ok", string(content))
-}
diff --git a/internal/cli/app.go b/internal/cli/app.go
new file mode 100644
index 0000000..5b575dd
--- /dev/null
+++ b/internal/cli/app.go
@@ -0,0 +1,115 @@
+package cli
+
+import (
+ "context"
+ "fmt"
+
+ analysisbiz "github.com/fuchencong/mooncode/internal/analysis/biz"
+ analysisdata "github.com/fuchencong/mooncode/internal/analysis/data"
+ analysisworkflow "github.com/fuchencong/mooncode/internal/analysis/workflow"
+ channelbiz "github.com/fuchencong/mooncode/internal/channel/biz"
+ channelcommand "github.com/fuchencong/mooncode/internal/channel/command"
+ channeldata "github.com/fuchencong/mooncode/internal/channel/data"
+ channelruntime "github.com/fuchencong/mooncode/internal/channel/runtime"
+ channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow"
+ identitybiz "github.com/fuchencong/mooncode/internal/identity/biz"
+ identitydata "github.com/fuchencong/mooncode/internal/identity/data"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/config"
+ "github.com/fuchencong/mooncode/internal/platform/database"
+ platformhatchet "github.com/fuchencong/mooncode/internal/platform/hatchet"
+ "github.com/fuchencong/mooncode/internal/platform/httpserver"
+ "github.com/fuchencong/mooncode/internal/platform/secret"
+ repositorybiz "github.com/fuchencong/mooncode/internal/repository/biz"
+ repositorydata "github.com/fuchencong/mooncode/internal/repository/data"
+ repositoryworkflow "github.com/fuchencong/mooncode/internal/repository/workflow"
+ retentiondata "github.com/fuchencong/mooncode/internal/retention/data"
+ retentionworkflow "github.com/fuchencong/mooncode/internal/retention/workflow"
+ workflow "github.com/fuchencong/mooncode/internal/workflow"
+ workflowdata "github.com/fuchencong/mooncode/internal/workflow/data"
+ "github.com/fuchencong/mooncode/pkg/analyzer/scc"
+ channelpkg "github.com/fuchencong/mooncode/pkg/channel"
+ "github.com/fuchencong/mooncode/pkg/channel/dingtalk"
+ "github.com/fuchencong/mooncode/pkg/channel/feishu"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type app struct {
+ config config.Config
+ pool *pgxpool.Pool
+ server *httpserver.Server
+ hatchet *platformhatchet.Client
+ dispatcher *workflow.Dispatcher
+ runtime *channelruntime.Manager
+ janitor gitrepo.WorktreeJanitor
+ metrics *httpserver.Metrics
+}
+
+func build(ctx context.Context, cfg config.Config) (*app, error) {
+ pool, err := database.Open(ctx, cfg.Database.URL, cfg.Database.MaxConnections)
+ if err != nil {
+ return nil, err
+ }
+ fail := func(err error) (*app, error) { pool.Close(); return nil, err }
+ if cfg.Database.MigrateOnStart {
+ if err := database.Migrate(ctx, pool); err != nil {
+ return fail(err)
+ }
+ }
+ cipher, err := secret.NewAESGCM(cfg.Secrets.Key, cfg.Secrets.KeyVersion)
+ if err != nil {
+ return fail(err)
+ }
+ git, err := gitrepo.NewManager(cfg.Repository.Root, gitrepo.WithMaxMirrorBytes(cfg.Repository.MaxMirrorBytes))
+ if err != nil {
+ return fail(err)
+ }
+ identityStore := identitydata.NewStore(pool)
+ identityService := identitybiz.NewService(identityStore, cfg.Auth.Issuer)
+ providerService := identitybiz.NewProviderService(identityStore, cipher, identitybiz.NewHTTPProviderValidator(nil))
+ repositoryStore := repositorydata.NewStore(pool, git, repositorydata.WithMaxRepositoriesPerWorkspace(cfg.Repository.MaxPerWorkspace))
+ analysisStore := analysisdata.NewStore(pool, analysisdata.WithMaxConcurrentRunsPerWorkspace(cfg.Analysis.MaxConcurrentPerWorkspace))
+ channelStore := channeldata.NewStore(pool)
+ retentionStore := retentiondata.NewStore(pool)
+ metrics := httpserver.NewMetrics()
+ feishuFactory := feishu.New()
+ dingTalkFactory := dingtalk.New()
+ repositoryRunner := repositoryworkflow.NewRunner(repositoryStore, identityService, providerService, git)
+ analysisRunner := analysisworkflow.NewRunner(analysisStore, identityService, git, scc.New(scc.WithBinary(cfg.Analysis.SCCPath), scc.WithMaxOutputBytes(cfg.Analysis.MaxOutputBytes)), cfg.Analysis.Timeout)
+ notificationRunner := channelworkflow.NewRunner(channelStore, cipher, feishuFactory, dingTalkFactory)
+ retentionScheduler := retentionworkflow.NewScheduler(retentionStore)
+ retentionRunner := retentionworkflow.NewRunner(retentionStore, git, retentionworkflow.WithMetrics(metrics))
+ hatchetClient, err := platformhatchet.New(
+ cfg.Hatchet.Token, cfg.Hatchet.Address, cfg.Hatchet.Namespace,
+ repositoryRunner, analysisRunner, notificationRunner, retentionScheduler, retentionRunner,
+ platformhatchet.WithMetrics(metrics),
+ )
+ if err != nil {
+ return fail(fmt.Errorf("create Hatchet client: %w", err))
+ }
+ repositoryService := repositorybiz.NewService(repositoryStore, identityService, providerService, hatchetClient, git)
+ analysisService := analysisbiz.NewService(analysisStore, identityService, repositoryStore, hatchetClient)
+ channelService := channelbiz.NewService(channelStore, identityService, cipher)
+ commandService := channelcommand.NewService(channelStore, identityService, repositoryService, analysisService, cfg.Auth.AppURL)
+ server, err := httpserver.New(httpserver.Config{
+ LogoutURL: cfg.Auth.LogoutURL,
+ RegistrationMode: cfg.Auth.RegistrationMode, TermsVersion: cfg.Auth.TermsVersion, PrivacyVersion: cfg.Auth.PrivacyVersion,
+ TrustedProxyCIDRs: cfg.Auth.TrustedProxyCIDRs,
+ MaxBodyBytes: cfg.HTTP.MaxBodyBytes, RequestTimeout: cfg.HTTP.RequestTimeout,
+ }, identityService, providerService, repositoryService, analysisService, channelService, commandService, auth.NewCSRF(cfg.Auth.CSRFKey),
+ httpserver.WithReadiness(database.NewReadiness(pool)), httpserver.WithMetrics(metrics),
+ )
+ if err != nil {
+ return fail(fmt.Errorf("create HTTP server: %w", err))
+ }
+ dispatcher := workflow.NewDispatcher(workflowdata.NewStore(pool), hatchetClient)
+ runtime := channelruntime.NewManager(channelStore, cipher, []channelpkg.Factory{feishuFactory, dingTalkFactory}, channelruntime.WithCommandHandler(commandService))
+
+ return &app{
+ config: cfg, pool: pool, server: server, hatchet: hatchetClient,
+ dispatcher: dispatcher, runtime: runtime, janitor: git, metrics: metrics,
+ }, nil
+}
+
+func (a *app) close() { a.pool.Close() }
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 5c42664..fcc09d2 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -2,131 +2,231 @@ package cli
import (
"context"
+ "errors"
"fmt"
"net/http"
+ "os/signal"
+ "syscall"
"time"
- "github.com/mooncode-ai/mooncode/internal/bootstrap"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/repository/postgres"
- "github.com/mooncode-ai/mooncode/internal/version"
+ "github.com/fuchencong/mooncode/internal/platform/config"
+ "github.com/fuchencong/mooncode/internal/platform/database"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
"github.com/spf13/cobra"
)
-func NewRootCommand(info version.Info) *cobra.Command {
- root := &cobra.Command{
- Use: "mooncode",
- Short: "MoonCode backend",
- SilenceErrors: true,
- SilenceUsage: true,
+func NewRoot() *cobra.Command {
+ var configPath string
+ root := &cobra.Command{Use: "mooncode", Short: "MoonCode code quality analysis SaaS"}
+ root.PersistentFlags().StringVar(&configPath, "config", "", "configuration file")
+ root.AddCommand(&cobra.Command{Use: "serve", Short: "Run the MoonCode HTTP API", RunE: func(command *cobra.Command, _ []string) error { return runServer(command.Context(), configPath) }})
+ root.AddCommand(&cobra.Command{Use: "worker", Short: "Run the MoonCode Hatchet worker", RunE: func(command *cobra.Command, _ []string) error { return runWorker(command.Context(), configPath) }})
+ root.AddCommand(newVersionCommand())
+ root.AddCommand(&cobra.Command{Use: "migrate", Short: "Apply MoonCode database migrations", RunE: func(command *cobra.Command, _ []string) error {
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ return err
+ }
+ pool, err := database.Open(command.Context(), cfg.Database.URL, cfg.Database.MaxConnections)
+ if err != nil {
+ return err
+ }
+ defer pool.Close()
+ return database.Migrate(command.Context(), pool)
+ }})
+ return root
+}
+
+func runServer(parent context.Context, path string) error {
+ cfg, err := config.Load(path)
+ if err != nil {
+ return err
+ }
+ ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ application, err := build(ctx, cfg)
+ if err != nil {
+ return err
+ }
+ defer application.close()
+ server := &http.Server{Addr: cfg.HTTP.Address, Handler: application.server.Handler(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 2 * time.Minute}
+ runtimeDone := make(chan struct{})
+ go func() {
+ defer close(runtimeDone)
+ _ = application.runtime.Run(ctx)
+ }()
+ errorsChannel := make(chan error, 1)
+ go func() { errorsChannel <- server.ListenAndServe() }()
+ select {
+ case <-ctx.Done():
+ shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ if err := server.Shutdown(shutdown); err != nil {
+ return err
+ }
+ select {
+ case <-runtimeDone:
+ return nil
+ case <-shutdown.Done():
+ return shutdown.Err()
+ }
+ case err := <-errorsChannel:
+ stop()
+ shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ select {
+ case <-runtimeDone:
+ case <-shutdown.Done():
+ }
+ if errors.Is(err, http.ErrServerClosed) {
+ return nil
+ }
+ return err
+ }
+}
+func runWorker(parent context.Context, path string) error {
+ cfg, err := config.Load(path)
+ if err != nil {
+ return err
+ }
+ ctx, stop := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+ application, err := build(ctx, cfg)
+ if err != nil {
+ return err
}
- root.AddCommand(
- newServeCommand(info),
- newVersionCommand(info),
- newHealthcheckCommand(),
- newMigrateCommand(),
+ defer application.close()
+ return runWorkerProcesses(
+ ctx,
+ application.dispatcher.Run,
+ func(workerContext context.Context) error {
+ return application.hatchet.StartWorker(workerContext, "mooncode-worker", 20)
+ },
+ namedWorkerProcess{
+ name: "worker metrics server",
+ run: func(metricsContext context.Context) error {
+ return serveWorkerMetrics(metricsContext, cfg.Metrics.WorkerAddress, application.metrics.Handler())
+ },
+ },
+ namedWorkerProcess{
+ name: "worktree janitor",
+ run: func(janitorContext context.Context) error {
+ return runWorktreeJanitor(janitorContext, application.janitor, cfg.Repository.WorktreeMaxAge)
+ },
+ },
)
- return root
}
-func newMigrateCommand() *cobra.Command {
- var configFile string
- migrateCommand := &cobra.Command{Use: "migrate", Short: "Manage MoonCode database migrations"}
- upCommand := &cobra.Command{
- Use: "up",
- Short: "Apply all forward PostgreSQL migrations",
- RunE: func(cmd *cobra.Command, _ []string) error {
- cfg, err := config.NewLoader().Load(configFile, nil)
- if err != nil {
+func runWorktreeJanitor(ctx context.Context, janitor gitrepo.WorktreeJanitor, maxAge time.Duration) error {
+ cleanup := func() error {
+ if _, err := janitor.CleanupStaleWorktrees(ctx, maxAge); err != nil {
+ return fmt.Errorf("clean stale analysis worktrees: %w", err)
+ }
+
+ return nil
+ }
+ if err := cleanup(); err != nil {
+ return err
+ }
+
+ ticker := time.NewTicker(time.Hour)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-ticker.C:
+ if err := cleanup(); err != nil {
return err
}
- return postgres.MigrateUp(cmd.Context(), cfg.Database)
- },
+ }
}
- upCommand.Flags().StringVar(&configFile, "config", "", "path to a YAML configuration file")
- migrateCommand.AddCommand(upCommand)
- return migrateCommand
}
-func newServeCommand(info version.Info) *cobra.Command {
- var (
- configFile string
- listen string
- adminListen string
- logLevel string
- )
+type workerProcess func(context.Context) error
- command := &cobra.Command{
- Use: "serve",
- Short: "Run the MoonCode backend",
- RunE: func(cmd *cobra.Command, _ []string) error {
- overrides := make(map[string]any)
- if cmd.Flags().Changed("listen-address") {
- overrides["server.address"] = listen
- }
- if cmd.Flags().Changed("admin-listen-address") {
- overrides["admin.address"] = adminListen
- }
- if cmd.Flags().Changed("log-level") {
- overrides["log.level"] = logLevel
- }
+type namedWorkerProcess struct {
+ name string
+ run workerProcess
+}
- cfg, err := config.NewLoader().Load(configFile, overrides)
- if err != nil {
- return err
- }
- app, err := bootstrap.New(cfg, bootstrap.BuildInfo{
- Version: info.Version,
- Commit: info.Commit,
- Date: info.Date,
- })
- if err != nil {
- return err
- }
- return app.Run(cmd.Context())
- },
+type workerProcessResult struct {
+ name string
+ err error
+}
+
+func runWorkerProcesses(ctx context.Context, dispatcher, worker workerProcess, additional ...namedWorkerProcess) error {
+ processContext, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ processes := append([]namedWorkerProcess{
+ {name: "workflow dispatcher", run: dispatcher},
+ {name: "Hatchet worker", run: worker},
+ }, additional...)
+ results := make(chan workerProcessResult, len(processes))
+ for _, process := range processes {
+ go func(process namedWorkerProcess) {
+ results <- workerProcessResult{name: process.name, err: process.run(processContext)}
+ }(process)
}
- command.Flags().StringVar(&configFile, "config", "", "path to a YAML configuration file")
- command.Flags().StringVar(&listen, "listen-address", "", "application HTTP listen address")
- command.Flags().StringVar(&adminListen, "admin-listen-address", "", "internal admin HTTP listen address")
- command.Flags().StringVar(&logLevel, "log-level", "", "log level (trace, debug, info, warn, error)")
- return command
+
+ first := <-results
+ cancel()
+ completed := []workerProcessResult{first}
+ for range len(processes) - 1 {
+ completed = append(completed, <-results)
+ }
+
+ return workerProcessError(ctx, completed)
}
-func newVersionCommand(info version.Info) *cobra.Command {
- return &cobra.Command{
- Use: "version",
- Short: "Print build version information",
- RunE: func(cmd *cobra.Command, _ []string) error {
- _, err := fmt.Fprintf(cmd.OutOrStdout(), "mooncode %s (commit %s, built %s)\n", info.Version, info.Commit, info.Date)
- return err
- },
+func workerProcessError(parent context.Context, results []workerProcessResult) error {
+ var processErrors []error
+ for index, result := range results {
+ if result.err == nil {
+ continue
+ }
+ if parent.Err() != nil && errors.Is(result.err, parent.Err()) {
+ continue
+ }
+ if index > 0 && errors.Is(result.err, context.Canceled) {
+ continue
+ }
+
+ processErrors = append(processErrors, fmt.Errorf("%s: %w", result.name, result.err))
}
+
+ return errors.Join(processErrors...)
}
-func newHealthcheckCommand() *cobra.Command {
- var endpoint string
- command := &cobra.Command{
- Use: "healthcheck",
- Short: "Check the internal readiness endpoint",
- RunE: func(cmd *cobra.Command, _ []string) error {
- ctx, cancel := context.WithTimeout(cmd.Context(), 3*time.Second)
- defer cancel()
- request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
- if err != nil {
- return fmt.Errorf("create healthcheck request: %w", err)
- }
- response, err := http.DefaultClient.Do(request)
- if err != nil {
- return fmt.Errorf("healthcheck request: %w", err)
- }
- defer func() { _ = response.Body.Close() }()
- if response.StatusCode != http.StatusOK {
- return fmt.Errorf("backend is not ready: %s", response.Status)
- }
+func serveWorkerMetrics(ctx context.Context, address string, metrics http.Handler) error {
+ router := http.NewServeMux()
+ router.Handle("/metrics", metrics)
+ router.HandleFunc("/healthz", func(writer http.ResponseWriter, _ *http.Request) {
+ writer.Header().Set("Cache-Control", "no-store")
+ writer.WriteHeader(http.StatusNoContent)
+ })
+ server := &http.Server{
+ Addr: address, Handler: router, ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: time.Minute,
+ }
+ errorsChannel := make(chan error, 1)
+ go func() { errorsChannel <- server.ListenAndServe() }()
+
+ select {
+ case <-ctx.Done():
+ shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := server.Shutdown(shutdown); err != nil {
+ return err
+ }
+
+ return nil
+ case err := <-errorsChannel:
+ if errors.Is(err, http.ErrServerClosed) {
return nil
- },
+ }
+
+ return err
}
- command.Flags().StringVar(&endpoint, "url", "http://127.0.0.1:9090/readyz", "readiness URL")
- return command
}
diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go
index 9a99353..179db96 100644
--- a/internal/cli/root_test.go
+++ b/internal/cli/root_test.go
@@ -1,27 +1,178 @@
package cli
import (
- "bytes"
+ "context"
+ "errors"
+ "strings"
"testing"
-
- "github.com/mooncode-ai/mooncode/internal/version"
- "github.com/stretchr/testify/require"
+ "time"
)
-func TestVersionCommand(t *testing.T) {
- output := new(bytes.Buffer)
- command := NewRootCommand(version.Info{Version: "1.2.3", Commit: "abc", Date: "today"})
- command.SetOut(output)
- command.SetArgs([]string{"version"})
+type worktreeJanitor struct {
+ calls int
+ maxAge time.Duration
+ err error
+}
+
+func (j *worktreeJanitor) CleanupStaleWorktrees(_ context.Context, maxAge time.Duration) (int, error) {
+ j.calls++
+ j.maxAge = maxAge
+
+ return 0, j.err
+}
+
+func TestWorktreeJanitorRunsAtStartupAndStopsWithContext(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ janitor := &worktreeJanitor{}
+
+ if err := runWorktreeJanitor(ctx, janitor, 24*time.Hour); err != nil {
+ t.Fatal(err)
+ }
+ if janitor.calls != 1 || janitor.maxAge != 24*time.Hour {
+ t.Fatalf("janitor startup = (%d,%s)", janitor.calls, janitor.maxAge)
+ }
+}
+
+func TestWorktreeJanitorReturnsCleanupFailure(t *testing.T) {
+ want := errors.New("worktree scan failed")
+ err := runWorktreeJanitor(context.Background(), &worktreeJanitor{err: want}, time.Hour)
+ if !errors.Is(err, want) {
+ t.Fatalf("runWorktreeJanitor() error = %v, want %v", err, want)
+ }
+}
+
+func TestRunWorkerProcessesReturnsDispatcherFailure(t *testing.T) {
+ want := errors.New("outbox unavailable")
+ workerStopped := make(chan struct{})
+
+ err := runWorkerProcesses(
+ context.Background(),
+ func(context.Context) error { return want },
+ func(ctx context.Context) error {
+ <-ctx.Done()
+ close(workerStopped)
+
+ return ctx.Err()
+ },
+ )
+
+ if !errors.Is(err, want) || !strings.Contains(err.Error(), "workflow dispatcher") {
+ t.Fatalf("runWorkerProcesses() error = %v, want dispatcher failure", err)
+ }
+ select {
+ case <-workerStopped:
+ default:
+ t.Fatal("Hatchet worker was not stopped")
+ }
+}
+
+func TestRunWorkerProcessesReturnsWorkerFailure(t *testing.T) {
+ want := errors.New("worker registration failed")
+ dispatcherStopped := make(chan struct{})
+
+ err := runWorkerProcesses(
+ context.Background(),
+ func(ctx context.Context) error {
+ <-ctx.Done()
+ close(dispatcherStopped)
+
+ return nil
+ },
+ func(context.Context) error { return want },
+ )
+
+ if !errors.Is(err, want) || !strings.Contains(err.Error(), "Hatchet worker") {
+ t.Fatalf("runWorkerProcesses() error = %v, want Hatchet worker failure", err)
+ }
+ select {
+ case <-dispatcherStopped:
+ default:
+ t.Fatal("workflow dispatcher was not stopped")
+ }
+}
+
+func TestRunWorkerProcessesReturnsMetricsFailure(t *testing.T) {
+ want := errors.New("metrics listener failed")
+ stopped := make(chan string, 2)
+ wait := func(name string) workerProcess {
+ return func(ctx context.Context) error {
+ <-ctx.Done()
+ stopped <- name
- require.NoError(t, command.Execute())
- require.Equal(t, "mooncode 1.2.3 (commit abc, built today)\n", output.String())
+ return ctx.Err()
+ }
+ }
+
+ err := runWorkerProcesses(
+ context.Background(),
+ wait("dispatcher"),
+ wait("worker"),
+ namedWorkerProcess{name: "worker metrics server", run: func(context.Context) error { return want }},
+ )
+ if !errors.Is(err, want) || !strings.Contains(err.Error(), "worker metrics server") {
+ t.Fatalf("runWorkerProcesses() error = %v, want metrics failure", err)
+ }
+ if errors.Is(err, context.Canceled) {
+ t.Fatalf("runWorkerProcesses() error = %v, want peer cancellations to be ignored", err)
+ }
+ for range 2 {
+ select {
+ case <-stopped:
+ case <-time.After(time.Second):
+ t.Fatal("a peer process was not stopped")
+ }
+ }
}
-func TestServeRejectsInvalidListener(t *testing.T) {
- command := NewRootCommand(version.Info{Version: "test"})
- command.SetArgs([]string{"serve", "--listen-address", "invalid"})
+func TestRunWorkerProcessesDoesNotHideUnexpectedCancellation(t *testing.T) {
+ err := runWorkerProcesses(
+ context.Background(),
+ func(context.Context) error { return context.Canceled },
+ func(ctx context.Context) error {
+ <-ctx.Done()
+
+ return ctx.Err()
+ },
+ )
+
+ if !errors.Is(err, context.Canceled) || !strings.Contains(err.Error(), "workflow dispatcher") {
+ t.Fatalf("runWorkerProcesses() error = %v, want unexpected dispatcher cancellation", err)
+ }
+}
+
+func TestRunWorkerProcessesStopsGracefully(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ dispatcherStarted := make(chan struct{})
+ workerStarted := make(chan struct{})
+ process := func(started chan<- struct{}) workerProcess {
+ return func(ctx context.Context) error {
+ close(started)
+ <-ctx.Done()
+
+ return ctx.Err()
+ }
+ }
+ done := make(chan error, 1)
+ go func() {
+ done <- runWorkerProcesses(ctx, process(dispatcherStarted), process(workerStarted))
+ }()
+
+ for _, started := range []<-chan struct{}{dispatcherStarted, workerStarted} {
+ select {
+ case <-started:
+ case <-time.After(time.Second):
+ t.Fatal("worker process did not start")
+ }
+ }
+ cancel()
- err := command.Execute()
- require.ErrorContains(t, err, "server.address is invalid")
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatalf("runWorkerProcesses() error = %v, want nil", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("worker processes did not stop")
+ }
}
diff --git a/internal/cli/version.go b/internal/cli/version.go
new file mode 100644
index 0000000..717036a
--- /dev/null
+++ b/internal/cli/version.go
@@ -0,0 +1,22 @@
+package cli
+
+import (
+ "fmt"
+
+ "github.com/fuchencong/mooncode/internal/platform/buildinfo"
+ "github.com/spf13/cobra"
+)
+
+func newVersionCommand() *cobra.Command {
+ return &cobra.Command{
+ Use: "version",
+ Short: "Print MoonCode build information",
+ Args: cobra.NoArgs,
+ RunE: func(command *cobra.Command, _ []string) error {
+ info := buildinfo.Current()
+ _, err := fmt.Fprintf(command.OutOrStdout(), "mooncode %s\ncommit: %s\nbuilt: %s\n", info.Version, info.Commit, info.BuildTime)
+
+ return err
+ },
+ }
+}
diff --git a/internal/cli/version_test.go b/internal/cli/version_test.go
new file mode 100644
index 0000000..7af110c
--- /dev/null
+++ b/internal/cli/version_test.go
@@ -0,0 +1,31 @@
+package cli
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/platform/buildinfo"
+)
+
+func TestVersionCommandReportsBuildIdentity(t *testing.T) {
+ previous := buildinfo.Current()
+ buildinfo.Version = "v1.2.3"
+ buildinfo.Commit = "0123456789abcdef"
+ buildinfo.BuildTime = "2026-07-30T10:00:00Z"
+ t.Cleanup(func() {
+ buildinfo.Version = previous.Version
+ buildinfo.Commit = previous.Commit
+ buildinfo.BuildTime = previous.BuildTime
+ })
+
+ output := new(bytes.Buffer)
+ command := newVersionCommand()
+ command.SetOut(output)
+ if err := command.Execute(); err != nil {
+ t.Fatal(err)
+ }
+ want := "mooncode v1.2.3\ncommit: 0123456789abcdef\nbuilt: 2026-07-30T10:00:00Z\n"
+ if output.String() != want {
+ t.Fatalf("version output = %q, want %q", output.String(), want)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
deleted file mode 100644
index 784f7db..0000000
--- a/internal/config/config.go
+++ /dev/null
@@ -1,463 +0,0 @@
-package config
-
-import (
- "encoding/base64"
- "errors"
- "fmt"
- "net"
- "os"
- "strings"
- "time"
-
- "github.com/spf13/viper"
-)
-
-type Config struct {
- Service ServiceConfig `mapstructure:"service"`
- Database DatabaseConfig `mapstructure:"database"`
- Identity IdentityConfig `mapstructure:"identity"`
- Registration RegistrationConfig `mapstructure:"registration"`
- SecretStore SecretStoreConfig `mapstructure:"secret_store"`
- Git GitConfig `mapstructure:"git"`
- Jobs JobsConfig `mapstructure:"jobs"`
- Outbox OutboxConfig `mapstructure:"outbox"`
- Channels ChannelsConfig `mapstructure:"channels"`
- Security SecurityConfig `mapstructure:"security"`
- Server HTTPServerConfig `mapstructure:"server"`
- Admin HTTPServerConfig `mapstructure:"admin"`
- Log LogConfig `mapstructure:"log"`
- Observability ObservabilityConfig `mapstructure:"observability"`
- Shutdown ShutdownConfig `mapstructure:"shutdown"`
-}
-
-type SecurityConfig struct {
- CSRF CSRFConfig `mapstructure:"csrf"`
- RateLimit RateLimitConfig `mapstructure:"rate_limit"`
-}
-type CSRFConfig struct {
- Key string `mapstructure:"key"`
- Secure bool `mapstructure:"secure"`
- TrustedOrigins []string `mapstructure:"trusted_origins"`
-}
-type RateLimitConfig struct {
- PerIP LimitConfig `mapstructure:"per_ip"`
- PerUser LimitConfig `mapstructure:"per_user"`
- PerWorkspace LimitConfig `mapstructure:"per_workspace"`
- EntryTTL time.Duration `mapstructure:"entry_ttl"`
- MaxEntries int `mapstructure:"max_entries"`
-}
-type LimitConfig struct {
- Rate float64 `mapstructure:"rate"`
- Burst float64 `mapstructure:"burst"`
-}
-
-type ChannelsConfig struct {
- FakeEnabled bool `mapstructure:"fake_enabled"`
- ReconcileInterval time.Duration `mapstructure:"reconcile_interval"`
- LeaseDuration time.Duration `mapstructure:"lease_duration"`
- DrainTimeout time.Duration `mapstructure:"drain_timeout"`
- RetryBaseDelay time.Duration `mapstructure:"retry_base_delay"`
- RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"`
- MutationCooldown time.Duration `mapstructure:"mutation_cooldown"`
- MutationTimeout time.Duration `mapstructure:"mutation_timeout"`
-}
-
-type GitConfig struct {
- RepositoryDirectory string `mapstructure:"repository_directory"`
- CloneTimeout time.Duration `mapstructure:"clone_timeout"`
- Depth int `mapstructure:"depth"`
- SyncCooldown time.Duration `mapstructure:"sync_cooldown"`
- ProbeCooldown time.Duration `mapstructure:"probe_cooldown"`
- AllowedPrivateCIDRs []string `mapstructure:"allowed_private_cidrs"`
-}
-
-type JobsConfig struct {
- PollInterval time.Duration `mapstructure:"poll_interval"`
- LeaseDuration time.Duration `mapstructure:"lease_duration"`
- RenewInterval time.Duration `mapstructure:"renew_interval"`
- RetryBaseDelay time.Duration `mapstructure:"retry_base_delay"`
-}
-
-type OutboxConfig struct {
- PollInterval time.Duration `mapstructure:"poll_interval"`
- BatchSize int `mapstructure:"batch_size"`
- LeaseDuration time.Duration `mapstructure:"lease_duration"`
- PublishTimeout time.Duration `mapstructure:"publish_timeout"`
- RetryBaseDelay time.Duration `mapstructure:"retry_base_delay"`
- RetryMaxDelay time.Duration `mapstructure:"retry_max_delay"`
-}
-
-type SecretStoreConfig struct {
- MasterKey string `mapstructure:"master_key"`
- KeyVersion int32 `mapstructure:"key_version"`
-}
-
-type IdentityConfig struct {
- Mode string `mapstructure:"mode"`
- Issuer string `mapstructure:"issuer"`
- AuthURL string `mapstructure:"auth_url"`
- GatewayToken string `mapstructure:"gateway_token"`
-}
-
-type RegistrationConfig struct {
- Mode string `mapstructure:"mode"`
- TermsVersion string `mapstructure:"terms_version"`
- PrivacyVersion string `mapstructure:"privacy_version"`
- InvitationTTL time.Duration `mapstructure:"invitation_ttl"`
- CreatePersonalWorkspace bool `mapstructure:"create_personal_workspace"`
-}
-
-type DatabaseConfig struct {
- URL string `mapstructure:"url"`
- MaxConnections int32 `mapstructure:"max_connections"`
- MinConnections int32 `mapstructure:"min_connections"`
- ConnectTimeout time.Duration `mapstructure:"connect_timeout"`
- HealthInterval time.Duration `mapstructure:"health_interval"`
- AutoMigrate bool `mapstructure:"auto_migrate"`
-}
-
-type ServiceConfig struct {
- Environment string `mapstructure:"environment"`
-}
-
-type HTTPServerConfig struct {
- Address string `mapstructure:"address"`
- ReadHeaderTimeout time.Duration `mapstructure:"read_header_timeout"`
- ReadTimeout time.Duration `mapstructure:"read_timeout"`
- WriteTimeout time.Duration `mapstructure:"write_timeout"`
- IdleTimeout time.Duration `mapstructure:"idle_timeout"`
-}
-
-type LogConfig struct {
- Level string `mapstructure:"level"`
- Format string `mapstructure:"format"`
-}
-
-type ObservabilityConfig struct {
- Tracing TracingConfig `mapstructure:"tracing"`
-}
-
-type TracingConfig struct {
- Enabled bool `mapstructure:"enabled"`
- Endpoint string `mapstructure:"endpoint"`
- Protocol string `mapstructure:"protocol"`
- SampleRatio float64 `mapstructure:"sample_ratio"`
- ExporterTimeout time.Duration `mapstructure:"exporter_timeout"`
-}
-
-type ShutdownConfig struct {
- Timeout time.Duration `mapstructure:"timeout"`
-}
-
-type Loader struct {
- v *viper.Viper
-}
-
-var (
- developmentSecretStoreKey = developmentKey(0)
- developmentCSRFKey = developmentKey(1)
-)
-
-func NewLoader() *Loader {
- v := viper.New()
- v.SetEnvPrefix("MOONCODE")
- v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
- v.AutomaticEnv()
-
- v.SetDefault("service.environment", "development")
- v.SetDefault("database.url", "postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable")
- v.SetDefault("database.max_connections", 20)
- v.SetDefault("database.min_connections", 2)
- v.SetDefault("database.connect_timeout", 10*time.Second)
- v.SetDefault("database.health_interval", 5*time.Second)
- v.SetDefault("database.auto_migrate", true)
- v.SetDefault("identity.mode", "oauth_subject_required")
- v.SetDefault("identity.issuer", "https://github.com")
- v.SetDefault("identity.auth_url", "http://auth.mooncode.localhost:3100")
- v.SetDefault("identity.gateway_token", "mooncode-local-gateway-token-change-me")
- v.SetDefault("registration.mode", "invite_only")
- v.SetDefault("registration.terms_version", "2026-07-01")
- v.SetDefault("registration.privacy_version", "2026-07-01")
- v.SetDefault("registration.invitation_ttl", 7*24*time.Hour)
- v.SetDefault("registration.create_personal_workspace", true)
- v.SetDefault("secret_store.master_key", developmentSecretStoreKey)
- v.SetDefault("secret_store.key_version", 1)
- v.SetDefault("git.repository_directory", "/var/lib/mooncode/repositories")
- v.SetDefault("git.clone_timeout", 10*time.Minute)
- v.SetDefault("git.depth", 1)
- v.SetDefault("git.sync_cooldown", 30*time.Second)
- v.SetDefault("git.probe_cooldown", 15*time.Second)
- v.SetDefault("git.allowed_private_cidrs", []string{})
- v.SetDefault("jobs.poll_interval", time.Second)
- v.SetDefault("jobs.lease_duration", 30*time.Second)
- v.SetDefault("jobs.renew_interval", 10*time.Second)
- v.SetDefault("jobs.retry_base_delay", 2*time.Second)
- v.SetDefault("outbox.poll_interval", time.Second)
- v.SetDefault("outbox.batch_size", 100)
- v.SetDefault("outbox.lease_duration", 30*time.Second)
- v.SetDefault("outbox.publish_timeout", 10*time.Second)
- v.SetDefault("outbox.retry_base_delay", 2*time.Second)
- v.SetDefault("outbox.retry_max_delay", time.Minute)
- v.SetDefault("channels.reconcile_interval", 10*time.Second)
- v.SetDefault("channels.fake_enabled", false)
- v.SetDefault("channels.lease_duration", 30*time.Second)
- v.SetDefault("channels.drain_timeout", 5*time.Second)
- v.SetDefault("channels.retry_base_delay", time.Second)
- v.SetDefault("channels.retry_max_delay", time.Minute)
- v.SetDefault("channels.mutation_cooldown", time.Second)
- v.SetDefault("channels.mutation_timeout", 30*time.Second)
- v.SetDefault("security.csrf.key", developmentCSRFKey)
- v.SetDefault("security.csrf.secure", false)
- v.SetDefault("security.csrf.trusted_origins", []string{"mooncode.localhost:3100"})
- v.SetDefault("security.rate_limit.per_ip.rate", 30.0)
- v.SetDefault("security.rate_limit.per_ip.burst", 60.0)
- v.SetDefault("security.rate_limit.per_user.rate", 20.0)
- v.SetDefault("security.rate_limit.per_user.burst", 40.0)
- v.SetDefault("security.rate_limit.per_workspace.rate", 50.0)
- v.SetDefault("security.rate_limit.per_workspace.burst", 100.0)
- v.SetDefault("security.rate_limit.entry_ttl", 10*time.Minute)
- v.SetDefault("security.rate_limit.max_entries", 10000)
- v.SetDefault("server.address", ":8080")
- v.SetDefault("server.read_header_timeout", 5*time.Second)
- v.SetDefault("server.read_timeout", 15*time.Second)
- v.SetDefault("server.write_timeout", 30*time.Second)
- v.SetDefault("server.idle_timeout", 60*time.Second)
- // Bind operational endpoints to loopback by default. A wildcard bind would
- // expose the admin listener on every Docker network joined by the single
- // backend container, including the edge network.
- v.SetDefault("admin.address", "127.0.0.1:9090")
- v.SetDefault("admin.read_header_timeout", 3*time.Second)
- v.SetDefault("admin.read_timeout", 5*time.Second)
- v.SetDefault("admin.write_timeout", 10*time.Second)
- v.SetDefault("admin.idle_timeout", 30*time.Second)
- v.SetDefault("log.level", "info")
- v.SetDefault("log.format", "json")
- v.SetDefault("observability.tracing.enabled", false)
- v.SetDefault("observability.tracing.protocol", "http/protobuf")
- v.SetDefault("observability.tracing.sample_ratio", 1.0)
- v.SetDefault("observability.tracing.exporter_timeout", 5*time.Second)
- v.SetDefault("shutdown.timeout", 15*time.Second)
-
- return &Loader{v: v}
-}
-
-func (l *Loader) Load(configFile string, overrides map[string]any) (Config, error) {
- if configFile != "" {
- l.v.SetConfigFile(configFile)
- if err := l.v.ReadInConfig(); err != nil {
- return Config{}, fmt.Errorf("read config: %w", err)
- }
- } else {
- l.v.SetConfigName("mooncode")
- l.v.SetConfigType("yaml")
- l.v.AddConfigPath(".")
- if err := l.v.ReadInConfig(); err != nil {
- var notFound viper.ConfigFileNotFoundError
- if !errors.As(err, ¬Found) {
- return Config{}, fmt.Errorf("read config: %w", err)
- }
- }
- }
-
- for key, value := range overrides {
- l.v.Set(key, value)
- }
-
- var cfg Config
- if err := l.v.UnmarshalExact(&cfg); err != nil {
- return Config{}, fmt.Errorf("decode config: %w", err)
- }
- if err := resolveSecretFiles(&cfg); err != nil {
- return Config{}, err
- }
- if err := cfg.Validate(); err != nil {
- return Config{}, err
- }
- return cfg, nil
-}
-
-func resolveSecretFiles(cfg *Config) error {
- values := []struct {
- environment string
- set func(string)
- }{
- {"MOONCODE_DATABASE_URL", func(value string) { cfg.Database.URL = value }},
- {"MOONCODE_IDENTITY_GATEWAY_TOKEN", func(value string) { cfg.Identity.GatewayToken = value }},
- {"MOONCODE_SECRET_STORE_MASTER_KEY", func(value string) { cfg.SecretStore.MasterKey = value }},
- {"MOONCODE_SECURITY_CSRF_KEY", func(value string) { cfg.Security.CSRF.Key = value }},
- }
- for _, value := range values {
- path, configured := os.LookupEnv(value.environment + "_FILE")
- if !configured || strings.TrimSpace(path) == "" {
- continue
- }
- if _, direct := os.LookupEnv(value.environment); direct {
- return fmt.Errorf("%s and %s_FILE cannot both be set", value.environment, value.environment)
- }
- contents, err := os.ReadFile(strings.TrimSpace(path))
- if err != nil {
- return fmt.Errorf("read %s_FILE: %w", value.environment, err)
- }
- resolved := strings.TrimSuffix(strings.TrimSuffix(string(contents), "\n"), "\r")
- if resolved == "" {
- return fmt.Errorf("%s_FILE is empty", value.environment)
- }
- value.set(resolved)
- }
- return nil
-}
-
-func (c Config) Validate() error {
- var errs []error
- if err := validateAddress("server.address", c.Server.Address); err != nil {
- errs = append(errs, err)
- }
- if err := validateAddress("admin.address", c.Admin.Address); err != nil {
- errs = append(errs, err)
- }
- if sameNonEphemeralAddress(c.Server.Address, c.Admin.Address) {
- errs = append(errs, errors.New("server.address and admin.address must differ"))
- }
- if strings.TrimSpace(c.Service.Environment) == "" {
- errs = append(errs, errors.New("service.environment is required"))
- }
- if strings.TrimSpace(c.Database.URL) == "" {
- errs = append(errs, errors.New("database.url is required"))
- }
- if c.Database.MaxConnections <= 0 {
- errs = append(errs, errors.New("database.max_connections must be positive"))
- }
- if c.Database.MinConnections < 0 || c.Database.MinConnections > c.Database.MaxConnections {
- errs = append(errs, errors.New("database.min_connections must be between zero and database.max_connections"))
- }
- if c.Database.ConnectTimeout <= 0 {
- errs = append(errs, errors.New("database.connect_timeout must be positive"))
- }
- if c.Database.HealthInterval <= 0 {
- errs = append(errs, errors.New("database.health_interval must be positive"))
- }
- if strings.TrimSpace(c.Identity.Issuer) == "" {
- errs = append(errs, errors.New("identity.issuer is required"))
- }
- if c.Identity.Mode != "oauth_subject_required" {
- errs = append(errs, errors.New("identity.mode must be oauth_subject_required"))
- }
- if strings.TrimSpace(c.Identity.AuthURL) == "" {
- errs = append(errs, errors.New("identity.auth_url is required"))
- }
- if len(c.Identity.GatewayToken) < 24 {
- errs = append(errs, errors.New("identity.gateway_token must contain at least 24 characters"))
- }
- if c.Registration.Mode != "disabled" && c.Registration.Mode != "invite_only" && c.Registration.Mode != "public" {
- errs = append(errs, errors.New("registration.mode must be disabled, invite_only, or public"))
- }
- if strings.TrimSpace(c.Registration.TermsVersion) == "" || strings.TrimSpace(c.Registration.PrivacyVersion) == "" {
- errs = append(errs, errors.New("registration terms_version and privacy_version are required"))
- }
- if c.Registration.InvitationTTL <= 0 {
- errs = append(errs, errors.New("registration.invitation_ttl must be positive"))
- }
- if strings.TrimSpace(c.SecretStore.MasterKey) == "" {
- errs = append(errs, errors.New("secret_store.master_key is required"))
- } else if c.Service.Environment != "development" && c.SecretStore.MasterKey == developmentSecretStoreKey {
- errs = append(errs, errors.New("secret_store.master_key must be configured outside development"))
- }
- if c.SecretStore.KeyVersion <= 0 {
- errs = append(errs, errors.New("secret_store.key_version must be positive"))
- }
- if strings.TrimSpace(c.Git.RepositoryDirectory) == "" || c.Git.CloneTimeout <= 0 || c.Git.Depth <= 0 || c.Git.Depth > 1000 || c.Git.SyncCooldown < 0 || c.Git.ProbeCooldown < 0 {
- errs = append(errs, errors.New("git repository_directory, clone_timeout, depth, sync_cooldown, and probe_cooldown are invalid"))
- }
- for _, cidr := range c.Git.AllowedPrivateCIDRs {
- if _, _, err := net.ParseCIDR(cidr); err != nil {
- errs = append(errs, fmt.Errorf("git.allowed_private_cidrs contains invalid CIDR %q", cidr))
- }
- }
- if c.Jobs.PollInterval <= 0 || c.Jobs.LeaseDuration <= 0 || c.Jobs.RenewInterval <= 0 || c.Jobs.RetryBaseDelay <= 0 || c.Jobs.RenewInterval >= c.Jobs.LeaseDuration {
- errs = append(errs, errors.New("jobs timing configuration is invalid"))
- }
- if c.Outbox.PollInterval <= 0 || c.Outbox.BatchSize <= 0 || c.Outbox.BatchSize > 1000 ||
- c.Outbox.LeaseDuration <= 0 || c.Outbox.PublishTimeout <= 0 || c.Outbox.PublishTimeout >= c.Outbox.LeaseDuration ||
- c.Outbox.RetryBaseDelay <= 0 || c.Outbox.RetryMaxDelay < c.Outbox.RetryBaseDelay {
- errs = append(errs, errors.New("outbox configuration is invalid"))
- }
- if c.Channels.ReconcileInterval <= 0 || c.Channels.LeaseDuration <= c.Channels.ReconcileInterval*2 || c.Channels.DrainTimeout <= 0 ||
- c.Channels.RetryBaseDelay <= 0 || c.Channels.RetryMaxDelay < c.Channels.RetryBaseDelay ||
- c.Channels.MutationCooldown < 0 || c.Channels.MutationTimeout <= 0 {
- errs = append(errs, errors.New("channels timing configuration is invalid"))
- }
- csrfKey, err := base64.StdEncoding.DecodeString(c.Security.CSRF.Key)
- if err != nil || len(csrfKey) != 32 {
- errs = append(errs, errors.New("security.csrf.key must be a base64-encoded 32-byte key"))
- } else if c.Service.Environment != "development" && c.Security.CSRF.Key == developmentCSRFKey {
- errs = append(errs, errors.New("security.csrf.key must be configured outside development"))
- }
- for _, limit := range []LimitConfig{c.Security.RateLimit.PerIP, c.Security.RateLimit.PerUser, c.Security.RateLimit.PerWorkspace} {
- if limit.Rate <= 0 || limit.Burst < 1 {
- errs = append(errs, errors.New("security rate limits must have positive rate and burst"))
- break
- }
- }
- if c.Security.RateLimit.EntryTTL <= 0 || c.Security.RateLimit.MaxEntries <= 0 {
- errs = append(errs, errors.New("security rate limit entry_ttl and max_entries must be positive"))
- }
- if _, err := parseLogLevel(c.Log.Level); err != nil {
- errs = append(errs, err)
- }
- if c.Log.Format != "json" && c.Log.Format != "console" {
- errs = append(errs, errors.New("log.format must be json or console"))
- }
- if c.Shutdown.Timeout <= 0 {
- errs = append(errs, errors.New("shutdown.timeout must be positive"))
- }
- if c.Observability.Tracing.SampleRatio < 0 || c.Observability.Tracing.SampleRatio > 1 {
- errs = append(errs, errors.New("observability.tracing.sample_ratio must be between 0 and 1"))
- }
- if c.Observability.Tracing.Enabled {
- if strings.TrimSpace(c.Observability.Tracing.Endpoint) == "" {
- errs = append(errs, errors.New("observability.tracing.endpoint is required when tracing is enabled"))
- }
- if c.Observability.Tracing.Protocol != "http/protobuf" {
- errs = append(errs, errors.New("observability.tracing.protocol must be http/protobuf"))
- }
- if c.Observability.Tracing.ExporterTimeout <= 0 {
- errs = append(errs, errors.New("observability.tracing.exporter_timeout must be positive"))
- }
- }
- return errors.Join(errs...)
-}
-
-func developmentKey(fill byte) string {
- key := make([]byte, 32)
- for index := range key {
- key[index] = fill
- }
- return base64.StdEncoding.EncodeToString(key)
-}
-
-func sameNonEphemeralAddress(first, second string) bool {
- if first != second {
- return false
- }
- _, port, err := net.SplitHostPort(first)
- return err != nil || port != "0"
-}
-
-func validateAddress(name, address string) error {
- if strings.TrimSpace(address) == "" {
- return fmt.Errorf("%s is required", name)
- }
- if _, _, err := net.SplitHostPort(address); err != nil {
- return fmt.Errorf("%s is invalid: %w", name, err)
- }
- return nil
-}
-
-func parseLogLevel(level string) (string, error) {
- switch strings.ToLower(strings.TrimSpace(level)) {
- case "trace", "debug", "info", "warn", "error":
- return strings.ToLower(strings.TrimSpace(level)), nil
- default:
- return "", fmt.Errorf("log.level %q is invalid", level)
- }
-}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
deleted file mode 100644
index 3b2e9d1..0000000
--- a/internal/config/config_test.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package config
-
-import (
- "encoding/base64"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestLoaderDefaults(t *testing.T) {
- cfg, err := NewLoader().Load("", nil)
- require.NoError(t, err)
- require.Equal(t, ":8080", cfg.Server.Address)
- require.Equal(t, "127.0.0.1:9090", cfg.Admin.Address)
- require.Equal(t, 15*time.Second, cfg.Shutdown.Timeout)
- require.False(t, cfg.Observability.Tracing.Enabled)
- require.False(t, cfg.Channels.FakeEnabled)
- require.Equal(t, "oauth_subject_required", cfg.Identity.Mode)
- require.Equal(t, "https://github.com", cfg.Identity.Issuer)
- require.Equal(t, "invite_only", cfg.Registration.Mode)
- csrfKey, decodeErr := base64.StdEncoding.DecodeString(cfg.Security.CSRF.Key)
- require.NoError(t, decodeErr)
- require.Len(t, csrfKey, 32)
-}
-
-func TestLoaderResolvesSecretFileEnvironment(t *testing.T) {
- path := filepath.Join(t.TempDir(), "gateway-token")
- require.NoError(t, os.WriteFile(path, []byte("a-file-backed-gateway-token-value\n"), 0o600))
- t.Setenv("MOONCODE_IDENTITY_GATEWAY_TOKEN_FILE", path)
-
- cfg, err := NewLoader().Load("", nil)
- require.NoError(t, err)
- require.Equal(t, "a-file-backed-gateway-token-value", cfg.Identity.GatewayToken)
-}
-
-func TestLoaderRejectsDirectAndFileSecretTogether(t *testing.T) {
- path := filepath.Join(t.TempDir(), "gateway-token")
- require.NoError(t, os.WriteFile(path, []byte("a-file-backed-gateway-token-value"), 0o600))
- t.Setenv("MOONCODE_IDENTITY_GATEWAY_TOKEN", "a-direct-gateway-token-value")
- t.Setenv("MOONCODE_IDENTITY_GATEWAY_TOKEN_FILE", path)
-
- _, err := NewLoader().Load("", nil)
- require.ErrorContains(t, err, "cannot both be set")
-}
-
-func TestConfigRejectsInvalidCSRFKey(t *testing.T) {
- _, err := NewLoader().Load("", map[string]any{"security.csrf.key": "not-base64"})
- require.ErrorContains(t, err, "base64-encoded 32-byte key")
-}
-
-func TestConfigRejectsDevelopmentKeysOutsideDevelopment(t *testing.T) {
- _, err := NewLoader().Load("", map[string]any{"service.environment": "production"})
- require.ErrorContains(t, err, "secret_store.master_key must be configured outside development")
- require.ErrorContains(t, err, "security.csrf.key must be configured outside development")
-}
-
-func TestLoaderOverrides(t *testing.T) {
- cfg, err := NewLoader().Load("", map[string]any{
- "server.address": ":18080",
- "log.level": "debug",
- })
- require.NoError(t, err)
- require.Equal(t, ":18080", cfg.Server.Address)
- require.Equal(t, "debug", cfg.Log.Level)
-}
-
-func TestConfigRejectsSameListeners(t *testing.T) {
- _, err := NewLoader().Load("", map[string]any{
- "server.address": ":8080",
- "admin.address": ":8080",
- })
- require.ErrorContains(t, err, "must differ")
-}
-
-func TestConfigRejectsLegacyIdentityAndRegistrationModes(t *testing.T) {
- _, err := NewLoader().Load("", map[string]any{"identity.mode": "username_fallback"})
- require.ErrorContains(t, err, "oauth_subject_required")
-
- _, err = NewLoader().Load("", map[string]any{"registration.mode": "automatic"})
- require.ErrorContains(t, err, "disabled, invite_only, or public")
-}
diff --git a/internal/controller/audit.go b/internal/controller/audit.go
deleted file mode 100644
index 92c14e5..0000000
--- a/internal/controller/audit.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package controller
-
-import (
- "net/http"
- "strconv"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/service"
-)
-
-type AuditController struct{ audits *service.AuditService }
-
-func NewAuditController(audits *service.AuditService) *AuditController {
- return &AuditController{audits: audits}
-}
-
-func (controller *AuditController) Register(api *gin.RouterGroup) {
- api.GET("/workspaces/:workspaceID/audit-logs", controller.list)
-}
-
-func (controller *AuditController) list(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- pageSize := int32(50)
- if raw := c.Query("pageSize"); raw != "" {
- if parsed, err := strconv.ParseInt(raw, 10, 32); err == nil {
- pageSize = int32(parsed)
- }
- }
- page, err := controller.audits.List(c.Request.Context(), principal, workspaceID, c.Query("cursor"), pageSize)
- if err != nil {
- writeServiceError(c, err, "audit.list_failed")
- return
- }
- c.JSON(http.StatusOK, page)
-}
diff --git a/internal/controller/channel.go b/internal/controller/channel.go
deleted file mode 100644
index c20d2df..0000000
--- a/internal/controller/channel.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package controller
-
-import (
- "net/http"
- "strconv"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/service"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
-)
-
-type ChannelController struct {
- channels *service.ChannelService
- messages *service.MessageService
-}
-
-func NewChannelController(channels *service.ChannelService, messages *service.MessageService) *ChannelController {
- return &ChannelController{channels: channels, messages: messages}
-}
-func (controller *ChannelController) Register(api *gin.RouterGroup) {
- api.GET("/channel-types", controller.types)
- workspaces := api.Group("/workspaces/:workspaceID")
- workspaces.GET("/channels", controller.list)
- workspaces.POST("/channels", controller.create)
- workspaces.PATCH("/channels/:channelID", controller.update)
- workspaces.DELETE("/channels/:channelID", controller.delete)
- workspaces.POST("/channels/:channelID/enable", controller.enable)
- workspaces.POST("/channels/:channelID/disable", controller.disable)
- workspaces.GET("/channels/:channelID/status", controller.status)
- workspaces.GET("/messages", controller.listMessages)
- workspaces.GET("/conversations", controller.listConversations)
-}
-
-func (controller *ChannelController) delete(c *gin.Context) {
- principal, workspaceID, channelID, ok := channelScope(c)
- if !ok {
- return
- }
- if err := controller.channels.Delete(c.Request.Context(), principal, workspaceID, channelID, c.GetHeader("Idempotency-Key")); err != nil {
- writeServiceError(c, err, "channel.delete_failed")
- return
- }
- c.Status(http.StatusNoContent)
-}
-func (controller *ChannelController) types(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{"items": controller.channels.Descriptors()})
-}
-
-type createChannelRequest struct {
- Type string `json:"type"`
- Name string `json:"name"`
- Values map[string]any `json:"values"`
- Secrets map[string]string `json:"secrets"`
- SenderAllowList []string `json:"senderAllowList"`
- GroupPolicy channelcore.GroupPolicy `json:"groupPolicy"`
-}
-
-func (controller *ChannelController) create(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- var request createChannelRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- value, err := controller.channels.Create(c.Request.Context(), principal, workspaceID, service.CreateChannelInput{Type: request.Type, Name: request.Name, Values: request.Values, Secrets: request.Secrets, SenderAllowList: request.SenderAllowList, GroupPolicy: request.GroupPolicy, IdempotencyKey: c.GetHeader("Idempotency-Key")})
- if err != nil {
- writeServiceError(c, err, "channel.create_failed")
- return
- }
- c.JSON(http.StatusCreated, value)
-}
-func (controller *ChannelController) list(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- values, err := controller.channels.List(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeServiceError(c, err, "channel.list_failed")
- return
- }
- items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.ChannelInstance) pageKey { return pageKey{time: value.CreatedAt, id: value.ID} })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
-
-type updateChannelRequest struct {
- Version int64 `json:"version"`
- Name *string `json:"name"`
- Values map[string]any `json:"values"`
- Secrets map[string]string `json:"secrets"`
- ClearSecretFields []string `json:"clearSecretFields"`
- SenderAllowList *[]string `json:"senderAllowList"`
- GroupPolicy *channelcore.GroupPolicy `json:"groupPolicy"`
-}
-
-func (controller *ChannelController) update(c *gin.Context) {
- principal, workspaceID, channelID, ok := channelScope(c)
- if !ok {
- return
- }
- var request updateChannelRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- value, err := controller.channels.Update(c.Request.Context(), principal, workspaceID, channelID, service.UpdateChannelInput{Version: request.Version, Name: request.Name, Values: request.Values, Secrets: request.Secrets, ClearSecretFields: request.ClearSecretFields, SenderAllowList: request.SenderAllowList, GroupPolicy: request.GroupPolicy, IdempotencyKey: c.GetHeader("Idempotency-Key")})
- if err != nil {
- writeServiceError(c, err, "channel.update_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-func (controller *ChannelController) enable(c *gin.Context) { controller.setEnabled(c, true) }
-func (controller *ChannelController) disable(c *gin.Context) { controller.setEnabled(c, false) }
-func (controller *ChannelController) setEnabled(c *gin.Context, enabled bool) {
- principal, workspaceID, channelID, ok := channelScope(c)
- if !ok {
- return
- }
- value, err := controller.channels.SetEnabled(c.Request.Context(), principal, workspaceID, channelID, enabled, c.GetHeader("Idempotency-Key"))
- if err != nil {
- writeServiceError(c, err, "channel.state_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-func (controller *ChannelController) status(c *gin.Context) {
- principal, workspaceID, channelID, ok := channelScope(c)
- if !ok {
- return
- }
- value, err := controller.channels.Status(c.Request.Context(), principal, workspaceID, channelID)
- if err != nil {
- writeServiceError(c, err, "channel.status_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-func channelScope(c *gin.Context) (model.Principal, uuid.UUID, uuid.UUID, bool) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return model.Principal{}, uuid.Nil, uuid.Nil, false
- }
- id, err := uuid.Parse(c.Param("channelID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "channel.invalid_id", "Invalid channel ID")
- return model.Principal{}, uuid.Nil, uuid.Nil, false
- }
- return principal, workspaceID, id, true
-}
-
-func (controller *ChannelController) listMessages(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- var channelID *uuid.UUID
- if raw := c.Query("channelId"); raw != "" {
- parsed, err := uuid.Parse(raw)
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "channel.invalid_id", "Invalid channel ID")
- return
- }
- channelID = &parsed
- }
- var conversationID *uuid.UUID
- if raw := c.Query("conversationId"); raw != "" {
- parsed, err := uuid.Parse(raw)
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "conversation.invalid_id", "Invalid conversation ID")
- return
- }
- conversationID = &parsed
- }
- pageSize := int32(50)
- if raw := c.Query("pageSize"); raw != "" {
- if parsed, err := strconv.ParseInt(raw, 10, 32); err == nil {
- pageSize = int32(parsed)
- }
- }
- page, err := controller.messages.List(c.Request.Context(), principal, workspaceID, channelID, conversationID, c.Query("cursor"), pageSize)
- if err != nil {
- writeServiceError(c, err, "message.list_failed")
- return
- }
- c.JSON(http.StatusOK, page)
-}
-func (controller *ChannelController) listConversations(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- values, err := controller.messages.Conversations(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeServiceError(c, err, "conversation.list_failed")
- return
- }
- items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.IMConversation) pageKey { return pageKey{time: value.UpdatedAt, id: value.ID} })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
diff --git a/internal/controller/health.go b/internal/controller/health.go
deleted file mode 100644
index 90e701f..0000000
--- a/internal/controller/health.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package controller
-
-import (
- "encoding/json"
- "net/http"
-
- "github.com/mooncode-ai/mooncode/internal/health"
-)
-
-type HealthController struct {
- state *health.State
-}
-
-func NewHealthController(state *health.State) *HealthController {
- return &HealthController{state: state}
-}
-
-func (controller *HealthController) Register(mux *http.ServeMux) {
- mux.HandleFunc("GET /livez", controller.live)
- mux.HandleFunc("GET /readyz", controller.ready)
-}
-
-func (controller *HealthController) live(writer http.ResponseWriter, _ *http.Request) {
- writeHealth(writer, http.StatusOK, "live")
-}
-
-func (controller *HealthController) ready(writer http.ResponseWriter, _ *http.Request) {
- if !controller.state.Ready() {
- writeHealth(writer, http.StatusServiceUnavailable, "not_ready")
- return
- }
- writeHealth(writer, http.StatusOK, "ready")
-}
-
-func writeHealth(writer http.ResponseWriter, status int, state string) {
- writer.Header().Set("Content-Type", "application/json")
- writer.WriteHeader(status)
- _ = json.NewEncoder(writer).Encode(map[string]string{"status": state})
-}
diff --git a/internal/controller/health_test.go b/internal/controller/health_test.go
deleted file mode 100644
index 9997ab5..0000000
--- a/internal/controller/health_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package controller
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/mooncode-ai/mooncode/internal/health"
- "github.com/stretchr/testify/require"
-)
-
-func TestHealthControllerReadiness(t *testing.T) {
- state := health.NewState()
- mux := http.NewServeMux()
- NewHealthController(state).Register(mux)
-
- response := httptest.NewRecorder()
- mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
- require.Equal(t, http.StatusServiceUnavailable, response.Code)
-
- state.SetReady(true)
- response = httptest.NewRecorder()
- mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
- require.Equal(t, http.StatusOK, response.Code)
-
- state.StartShutdown()
- response = httptest.NewRecorder()
- mux.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
- require.Equal(t, http.StatusServiceUnavailable, response.Code)
-}
diff --git a/internal/controller/identity.go b/internal/controller/identity.go
deleted file mode 100644
index 8ab3d04..0000000
--- a/internal/controller/identity.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package controller
-
-import (
- "errors"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
- "github.com/mooncode-ai/mooncode/internal/middleware"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/service"
-)
-
-type IdentityController struct {
- identities *service.IdentityService
-}
-
-func NewIdentityController(identities *service.IdentityService) *IdentityController {
- return &IdentityController{identities: identities}
-}
-
-func (controller *IdentityController) Register(api *gin.RouterGroup) {
- api.GET("/session", controller.session)
- api.GET("/workspaces", controller.workspaces)
- api.GET("/workspaces/:workspaceID", controller.workspace)
-}
-
-func (controller *IdentityController) session(c *gin.Context) {
- principal, ok := identityctx.PrincipalFromContext(c.Request.Context())
- if !ok {
- writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required")
- return
- }
- session, err := controller.identities.Session(c.Request.Context(), principal)
- if err != nil {
- writeAPIError(c, http.StatusInternalServerError, "session.failed", "Unable to load session")
- return
- }
- c.JSON(http.StatusOK, session)
-}
-
-func (controller *IdentityController) workspaces(c *gin.Context) {
- principal, ok := identityctx.PrincipalFromContext(c.Request.Context())
- if !ok {
- writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required")
- return
- }
- session, err := controller.identities.Session(c.Request.Context(), principal)
- if err != nil {
- writeAPIError(c, http.StatusInternalServerError, "workspace.list_failed", "Unable to list workspaces")
- return
- }
- items, nextCursor, err := paginate(session.Workspaces, c.Query("cursor"), c.Query("pageSize"), func(value model.Workspace) pageKey {
- return pageKey{time: value.CreatedAt, id: value.ID}
- })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
-
-func (controller *IdentityController) workspace(c *gin.Context) {
- principal, ok := identityctx.PrincipalFromContext(c.Request.Context())
- if !ok {
- writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required")
- return
- }
- workspaceID, err := uuid.Parse(c.Param("workspaceID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "workspace.invalid_id", "Invalid workspace ID")
- return
- }
- workspace, err := controller.identities.Workspace(c.Request.Context(), principal, workspaceID)
- if errors.Is(err, repository.ErrNotFound) {
- writeAPIError(c, http.StatusNotFound, "workspace.not_found", "Workspace not found")
- return
- }
- if err != nil {
- writeAPIError(c, http.StatusInternalServerError, "workspace.failed", "Unable to load workspace")
- return
- }
- c.JSON(http.StatusOK, workspace)
-}
-
-func writeAPIError(c *gin.Context, status int, code, message string) {
- c.JSON(status, gin.H{"error": gin.H{
- "code": code, "message": message, "requestId": middleware.RequestIDFromContext(c.Request.Context()),
- }})
-}
diff --git a/internal/controller/overview.go b/internal/controller/overview.go
deleted file mode 100644
index bed5577..0000000
--- a/internal/controller/overview.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package controller
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/service"
-)
-
-type OverviewController struct{ overview *service.OverviewService }
-
-func NewOverviewController(overview *service.OverviewService) *OverviewController {
- return &OverviewController{overview: overview}
-}
-
-func (controller *OverviewController) Register(api *gin.RouterGroup) {
- api.GET("/workspaces/:workspaceID/overview", controller.get)
-}
-
-func (controller *OverviewController) get(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- value, err := controller.overview.Get(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeServiceError(c, err, "overview.get_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
diff --git a/internal/controller/pagination.go b/internal/controller/pagination.go
deleted file mode 100644
index 1ce31fc..0000000
--- a/internal/controller/pagination.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package controller
-
-import (
- "bytes"
- "encoding/base64"
- "errors"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "github.com/google/uuid"
-)
-
-type pageKey struct {
- time time.Time
- id uuid.UUID
-}
-
-func paginate[T any](values []T, rawCursor, rawPageSize string, key func(T) pageKey) ([]T, string, error) {
- pageSize := 50
- if rawPageSize != "" {
- parsed, err := strconv.Atoi(rawPageSize)
- if err != nil || parsed < 1 || parsed > 100 {
- return nil, "", errors.New("page size must be between 1 and 100")
- }
- pageSize = parsed
- }
- cursor, err := decodePageKey(rawCursor)
- if err != nil {
- return nil, "", err
- }
- ordered := append([]T(nil), values...)
- sort.SliceStable(ordered, func(left, right int) bool {
- leftKey, rightKey := key(ordered[left]), key(ordered[right])
- if !leftKey.time.Equal(rightKey.time) {
- return leftKey.time.After(rightKey.time)
- }
- return bytes.Compare(leftKey.id[:], rightKey.id[:]) > 0
- })
- filtered := make([]T, 0, min(len(ordered), pageSize+1))
- for _, value := range ordered {
- candidate := key(value)
- if rawCursor != "" && !pageKeyBefore(candidate, cursor) {
- continue
- }
- filtered = append(filtered, value)
- if len(filtered) == pageSize+1 {
- break
- }
- }
- if len(filtered) <= pageSize {
- return filtered, "", nil
- }
- items := filtered[:pageSize]
- return items, encodePageKey(key(items[len(items)-1])), nil
-}
-
-func pageKeyBefore(left, right pageKey) bool {
- if !left.time.Equal(right.time) {
- return left.time.Before(right.time)
- }
- return bytes.Compare(left.id[:], right.id[:]) < 0
-}
-
-func encodePageKey(value pageKey) string {
- plain := value.time.UTC().Format(time.RFC3339Nano) + "|" + value.id.String()
- return base64.RawURLEncoding.EncodeToString([]byte(plain))
-}
-
-func decodePageKey(value string) (pageKey, error) {
- if value == "" {
- return pageKey{}, nil
- }
- decoded, err := base64.RawURLEncoding.DecodeString(value)
- if err != nil {
- return pageKey{}, errors.New("invalid cursor")
- }
- parts := strings.Split(string(decoded), "|")
- if len(parts) != 2 {
- return pageKey{}, errors.New("invalid cursor")
- }
- timestamp, err := time.Parse(time.RFC3339Nano, parts[0])
- if err != nil {
- return pageKey{}, errors.New("invalid cursor")
- }
- id, err := uuid.Parse(parts[1])
- if err != nil {
- return pageKey{}, errors.New("invalid cursor")
- }
- return pageKey{time: timestamp, id: id}, nil
-}
diff --git a/internal/controller/pagination_test.go b/internal/controller/pagination_test.go
deleted file mode 100644
index d3e3047..0000000
--- a/internal/controller/pagination_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package controller
-
-import (
- "testing"
- "time"
-
- "github.com/google/uuid"
- "github.com/stretchr/testify/require"
-)
-
-func TestPaginateUsesStableTimeAndIDCursor(t *testing.T) {
- now := time.Now().UTC()
- type item struct {
- id uuid.UUID
- at time.Time
- }
- values := []item{
- {id: uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd"), at: now.Add(-time.Second)},
- {id: uuid.MustParse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), at: now},
- {id: uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff"), at: now},
- }
- key := func(value item) pageKey { return pageKey{time: value.at, id: value.id} }
-
- first, cursor, err := paginate(values, "", "2", key)
- require.NoError(t, err)
- require.Len(t, first, 2)
- require.Equal(t, uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff"), first[0].id)
- require.Equal(t, uuid.MustParse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"), first[1].id)
- require.NotEmpty(t, cursor)
-
- second, next, err := paginate(values, cursor, "2", key)
- require.NoError(t, err)
- require.Len(t, second, 1)
- require.Equal(t, uuid.MustParse("dddddddd-dddd-dddd-dddd-dddddddddddd"), second[0].id)
- require.Empty(t, next)
-}
-
-func TestPaginateRejectsInvalidInputs(t *testing.T) {
- _, _, err := paginate([]int{}, "invalid", "50", func(int) pageKey { return pageKey{} })
- require.Error(t, err)
- _, _, err = paginate([]int{}, "", "101", func(int) pageKey { return pageKey{} })
- require.Error(t, err)
-}
diff --git a/internal/controller/registration.go b/internal/controller/registration.go
deleted file mode 100644
index e34ee01..0000000
--- a/internal/controller/registration.go
+++ /dev/null
@@ -1,196 +0,0 @@
-package controller
-
-import (
- "errors"
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
- "github.com/mooncode-ai/mooncode/internal/middleware"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/service"
-)
-
-type RegistrationController struct {
- registrations *service.RegistrationService
- identities *service.IdentityService
-}
-
-func NewRegistrationController(registrations *service.RegistrationService, identities *service.IdentityService) *RegistrationController {
- return &RegistrationController{registrations: registrations, identities: identities}
-}
-
-func (controller *RegistrationController) Register(api *gin.RouterGroup) {
- api.GET("/onboarding", controller.onboarding)
- api.POST("/onboarding/complete", controller.complete)
- api.POST("/invitations/accept", controller.accept)
- api.GET("/workspaces/:workspaceID/invitations", controller.list)
- api.POST("/workspaces/:workspaceID/invitations", controller.create)
- api.DELETE("/workspaces/:workspaceID/invitations/:invitationID", controller.revoke)
-}
-
-func (controller *RegistrationController) onboarding(c *gin.Context) {
- principal, ok := registrationPrincipal(c)
- if !ok {
- return
- }
- session, err := controller.identities.Session(c.Request.Context(), principal)
- if err != nil {
- writeAPIError(c, http.StatusInternalServerError, "onboarding.failed", "Unable to load registration")
- return
- }
- c.Header("Cache-Control", "no-store")
- c.JSON(http.StatusOK, session)
-}
-
-type agreementRequest struct {
- AcceptTerms bool `json:"acceptTerms"`
- AcceptPrivacy bool `json:"acceptPrivacy"`
-}
-
-func (controller *RegistrationController) complete(c *gin.Context) {
- principal, ok := registrationPrincipal(c)
- if !ok {
- return
- }
- var request agreementRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- session, err := controller.registrations.Complete(c.Request.Context(), principal, service.CompleteRegistrationInput{
- AcceptTerms: request.AcceptTerms, AcceptPrivacy: request.AcceptPrivacy,
- RequestID: middleware.RequestIDFromContext(c.Request.Context()),
- })
- if err != nil {
- writeRegistrationError(c, err, "onboarding.failed")
- return
- }
- c.JSON(http.StatusOK, session)
-}
-
-type invitationRequest struct {
- Email string `json:"email"`
- Role string `json:"role"`
-}
-
-func (controller *RegistrationController) create(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- var request invitationRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- invitation, err := controller.registrations.CreateInvitation(c.Request.Context(), principal, workspaceID, service.CreateInvitationInput{
- Email: request.Email, Role: request.Role,
- })
- if err != nil {
- writeRegistrationError(c, err, "invitation.create_failed")
- return
- }
- c.JSON(http.StatusCreated, invitation)
-}
-
-func (controller *RegistrationController) list(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- invitations, err := controller.registrations.ListInvitations(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeRegistrationError(c, err, "invitation.list_failed")
- return
- }
- items, nextCursor, err := paginate(invitations, c.Query("cursor"), c.Query("pageSize"), func(value model.WorkspaceInvitation) pageKey {
- return pageKey{time: value.CreatedAt, id: value.ID}
- })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
-
-func (controller *RegistrationController) revoke(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- invitationID, err := uuid.Parse(c.Param("invitationID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "invitation.invalid_id", "Invalid invitation ID")
- return
- }
- if err := controller.registrations.RevokeInvitation(c.Request.Context(), principal, workspaceID, invitationID); err != nil {
- writeRegistrationError(c, err, "invitation.revoke_failed")
- return
- }
- c.Status(http.StatusNoContent)
-}
-
-type acceptInvitationRequest struct {
- Token string `json:"token"`
- AcceptTerms bool `json:"acceptTerms"`
- AcceptPrivacy bool `json:"acceptPrivacy"`
-}
-
-func (controller *RegistrationController) accept(c *gin.Context) {
- principal, ok := registrationPrincipal(c)
- if !ok {
- return
- }
- var request acceptInvitationRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- session, err := controller.registrations.AcceptInvitation(c.Request.Context(), principal, service.AcceptInvitationInput{
- Token: request.Token, AcceptTerms: request.AcceptTerms, AcceptPrivacy: request.AcceptPrivacy,
- RequestID: middleware.RequestIDFromContext(c.Request.Context()),
- })
- if err != nil {
- writeRegistrationError(c, err, "invitation.accept_failed")
- return
- }
- c.JSON(http.StatusOK, session)
-}
-
-func registrationPrincipal(c *gin.Context) (model.Principal, bool) {
- principal, ok := identityctx.PrincipalFromContext(c.Request.Context())
- if !ok {
- writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required")
- }
- return principal, ok
-}
-
-func writeRegistrationError(c *gin.Context, err error, fallback string) {
- switch {
- case errors.Is(err, service.ErrInvitationInvalid):
- writeAPIError(c, http.StatusBadRequest, "invitation.invalid", "Invitation is invalid")
- case errors.Is(err, service.ErrInvitationRequired):
- writeAPIError(c, http.StatusForbidden, "registration.invitation_required", "A workspace invitation is required")
- case errors.Is(err, service.ErrRegistrationClosed):
- writeAPIError(c, http.StatusForbidden, "registration.closed", "Registration is closed")
- case errors.Is(err, service.ErrInsufficientRole):
- writeAPIError(c, http.StatusForbidden, "authorization.insufficient_role", "Insufficient workspace role")
- case errors.Is(err, repository.ErrNotFound):
- writeAPIError(c, http.StatusNotFound, "resource.not_found", "Resource not found")
- case errors.Is(err, repository.ErrConflict):
- writeAPIError(c, http.StatusConflict, "resource.conflict", "Resource state changed")
- case errors.Is(err, service.ErrAccountSuspended):
- writeAPIError(c, http.StatusForbidden, "account.suspended", "Account is suspended")
- case errors.Is(err, service.ErrAccountDeleted):
- writeAPIError(c, http.StatusForbidden, "account.unavailable", "Account is unavailable")
- case errors.Is(err, service.ErrOnboardingRequired), errors.Is(err, service.ErrSubjectRequired):
- writeAPIError(c, http.StatusForbidden, "account.onboarding_required", "Complete account registration")
- case errors.Is(err, service.ErrAgreementsRequired):
- writeAPIError(c, http.StatusBadRequest, "registration.agreements_required", "Accept the terms and privacy policy")
- default:
- writeServiceError(c, err, fallback)
- }
-}
diff --git a/internal/controller/repository.go b/internal/controller/repository.go
deleted file mode 100644
index f78c1f0..0000000
--- a/internal/controller/repository.go
+++ /dev/null
@@ -1,345 +0,0 @@
-package controller
-
-import (
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/service"
- "github.com/mooncode-ai/mooncode/pkg/scm"
-)
-
-type RepositoryController struct {
- repositories *service.RepositoryService
- connections *service.SCMConnectionService
-}
-
-func NewRepositoryController(repositories *service.RepositoryService, connections *service.SCMConnectionService) *RepositoryController {
- return &RepositoryController{repositories: repositories, connections: connections}
-}
-
-func (controller *RepositoryController) Register(api *gin.RouterGroup) {
- workspaces := api.Group("/workspaces/:workspaceID")
- workspaces.GET("/scm-connections", controller.listConnections)
- workspaces.POST("/scm-connections", controller.createConnection)
- workspaces.PATCH("/scm-connections/:connectionID", controller.updateConnection)
- workspaces.DELETE("/scm-connections/:connectionID", controller.deleteConnection)
- workspaces.POST("/scm-connections/:connectionID/test", controller.testConnection)
- workspaces.GET("/repositories", controller.listRepositories)
- workspaces.POST("/repositories", controller.createRepository)
- workspaces.GET("/repositories/:repositoryID", controller.getRepository)
- workspaces.DELETE("/repositories/:repositoryID", controller.deleteRepository)
- workspaces.POST("/repositories/:repositoryID/sync", controller.syncRepository)
- workspaces.POST("/repositories/:repositoryID/cancel-sync", controller.cancelRepositorySync)
- workspaces.GET("/jobs/:jobID", controller.getJob)
- workspaces.POST("/jobs/:jobID/cancel", controller.cancelJob)
-}
-
-func (controller *RepositoryController) deleteConnection(c *gin.Context) {
- principal, workspaceID, connectionID, ok := connectionScope(c)
- if !ok {
- return
- }
- if err := controller.connections.Delete(c.Request.Context(), principal, workspaceID, connectionID); err != nil {
- writeServiceError(c, err, "connection.delete_failed")
- return
- }
- c.Status(http.StatusNoContent)
-}
-
-type createConnectionRequest struct {
- Type string `json:"type"`
- Name string `json:"name"`
- BaseURL string `json:"baseUrl"`
- AuthType string `json:"authType"`
- Secrets map[string]string `json:"secrets"`
-}
-
-type updateConnectionRequest struct {
- Name *string `json:"name"`
- BaseURL *string `json:"baseUrl"`
- AuthType *string `json:"authType"`
- Secrets map[string]string `json:"secrets"`
- ClearSecrets bool `json:"clearSecrets"`
-}
-
-func (controller *RepositoryController) updateConnection(c *gin.Context) {
- principal, workspaceID, connectionID, ok := connectionScope(c)
- if !ok {
- return
- }
- var request updateConnectionRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- value, err := controller.connections.Update(c.Request.Context(), principal, workspaceID, connectionID, service.UpdateSCMConnectionInput{
- Name: request.Name, BaseURL: request.BaseURL, AuthType: request.AuthType, Secrets: request.Secrets, ClearSecrets: request.ClearSecrets,
- })
- if err != nil {
- writeServiceError(c, err, "connection.update_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-
-func (controller *RepositoryController) testConnection(c *gin.Context) {
- principal, workspaceID, connectionID, ok := connectionScope(c)
- if !ok {
- return
- }
- var request struct {
- RepositoryURL string `json:"repositoryUrl"`
- }
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- if err := controller.connections.Test(c.Request.Context(), principal, workspaceID, connectionID, request.RepositoryURL, c.GetHeader("Idempotency-Key")); err != nil {
- writeServiceError(c, err, "connection.test_failed")
- return
- }
- c.JSON(http.StatusOK, gin.H{"ok": true})
-}
-
-func (controller *RepositoryController) createConnection(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- var request createConnectionRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- value, err := controller.connections.Create(c.Request.Context(), principal, workspaceID, service.CreateSCMConnectionInput{Type: request.Type, Name: request.Name, BaseURL: request.BaseURL, AuthType: request.AuthType, Secrets: request.Secrets})
- if err != nil {
- writeServiceError(c, err, "connection.create_failed")
- return
- }
- c.JSON(http.StatusCreated, value)
-}
-func (controller *RepositoryController) listConnections(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- values, err := controller.connections.List(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeServiceError(c, err, "connection.list_failed")
- return
- }
- items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.SCMConnection) pageKey { return pageKey{time: value.CreatedAt, id: value.ID} })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
-
-type createRepositoryRequest struct {
- Name string `json:"name"`
- CloneURL string `json:"cloneUrl"`
- ProviderType string `json:"providerType"`
- ConnectionID *uuid.UUID `json:"connectionId"`
- Ref string `json:"ref"`
-}
-
-func (controller *RepositoryController) createRepository(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- var request createRepositoryRequest
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- value, job, err := controller.repositories.Create(c.Request.Context(), principal, workspaceID, service.CreateRepositoryInput{Name: request.Name, CloneURL: request.CloneURL, ProviderType: request.ProviderType, ConnectionID: request.ConnectionID, Ref: request.Ref, IdempotencyKey: c.GetHeader("Idempotency-Key")})
- if err != nil {
- writeServiceError(c, err, "repository.create_failed")
- return
- }
- c.JSON(http.StatusAccepted, gin.H{"repository": value, "job": job})
-}
-func (controller *RepositoryController) listRepositories(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- values, err := controller.repositories.List(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeServiceError(c, err, "repository.list_failed")
- return
- }
- items, nextCursor, err := paginate(values, c.Query("cursor"), c.Query("pageSize"), func(value model.Repository) pageKey { return pageKey{time: value.CreatedAt, id: value.ID} })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
-func (controller *RepositoryController) getRepository(c *gin.Context) {
- principal, workspaceID, resourceID, ok := repositoryScope(c)
- if !ok {
- return
- }
- value, err := controller.repositories.Get(c.Request.Context(), principal, workspaceID, resourceID)
- if err != nil {
- writeServiceError(c, err, "repository.get_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-func (controller *RepositoryController) deleteRepository(c *gin.Context) {
- principal, workspaceID, resourceID, ok := repositoryScope(c)
- if !ok {
- return
- }
- if err := controller.repositories.Delete(c.Request.Context(), principal, workspaceID, resourceID); err != nil {
- writeServiceError(c, err, "repository.delete_failed")
- return
- }
- c.Status(http.StatusNoContent)
-}
-func (controller *RepositoryController) syncRepository(c *gin.Context) {
- principal, workspaceID, resourceID, ok := repositoryScope(c)
- if !ok {
- return
- }
- var request struct {
- Ref *string `json:"ref"`
- }
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- job, err := controller.repositories.Sync(c.Request.Context(), principal, workspaceID, resourceID, request.Ref, c.GetHeader("Idempotency-Key"))
- if err != nil {
- writeServiceError(c, err, "repository.sync_failed")
- return
- }
- c.JSON(http.StatusAccepted, gin.H{"job": job})
-}
-func (controller *RepositoryController) cancelRepositorySync(c *gin.Context) {
- principal, workspaceID, resourceID, ok := repositoryScope(c)
- if !ok {
- return
- }
- job, err := controller.repositories.CancelSync(c.Request.Context(), principal, workspaceID, resourceID)
- if err != nil {
- writeServiceError(c, err, "repository.cancel_failed")
- return
- }
- c.JSON(http.StatusOK, gin.H{"job": job})
-}
-func (controller *RepositoryController) getJob(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- jobID, err := uuid.Parse(c.Param("jobID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "job.invalid_id", "Invalid job ID")
- return
- }
- value, err := controller.repositories.Job(c.Request.Context(), principal, workspaceID, jobID)
- if err != nil {
- writeServiceError(c, err, "job.get_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-
-func (controller *RepositoryController) cancelJob(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- jobID, err := uuid.Parse(c.Param("jobID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "job.invalid_id", "Invalid job ID")
- return
- }
- value, err := controller.repositories.CancelJob(c.Request.Context(), principal, workspaceID, jobID)
- if err != nil {
- writeServiceError(c, err, "job.cancel_failed")
- return
- }
- c.JSON(http.StatusOK, value)
-}
-
-func requestScope(c *gin.Context) (model.Principal, uuid.UUID, bool) {
- principal, ok := identityctx.PrincipalFromContext(c.Request.Context())
- if !ok {
- writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required")
- return model.Principal{}, uuid.Nil, false
- }
- workspaceID, err := uuid.Parse(c.Param("workspaceID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "workspace.invalid_id", "Invalid workspace ID")
- return model.Principal{}, uuid.Nil, false
- }
- return principal, workspaceID, true
-}
-func repositoryScope(c *gin.Context) (model.Principal, uuid.UUID, uuid.UUID, bool) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return model.Principal{}, uuid.Nil, uuid.Nil, false
- }
- resourceID, err := uuid.Parse(c.Param("repositoryID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "repository.invalid_id", "Invalid repository ID")
- return model.Principal{}, uuid.Nil, uuid.Nil, false
- }
- return principal, workspaceID, resourceID, true
-}
-
-func connectionScope(c *gin.Context) (model.Principal, uuid.UUID, uuid.UUID, bool) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return model.Principal{}, uuid.Nil, uuid.Nil, false
- }
- resourceID, err := uuid.Parse(c.Param("connectionID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "connection.invalid_id", "Invalid connection ID")
- return model.Principal{}, uuid.Nil, uuid.Nil, false
- }
- return principal, workspaceID, resourceID, true
-}
-
-func decodeJSON(c *gin.Context, destination any) error {
- c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
- decoder := json.NewDecoder(c.Request.Body)
- decoder.DisallowUnknownFields()
- if err := decoder.Decode(destination); err != nil {
- return err
- }
- if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
- return errors.New("request must contain one JSON value")
- }
- return nil
-}
-func writeServiceError(c *gin.Context, err error, fallback string) {
- switch {
- case errors.Is(err, repository.ErrNotFound):
- writeAPIError(c, http.StatusNotFound, "resource.not_found", "Resource not found")
- case errors.Is(err, repository.ErrConflict):
- writeAPIError(c, http.StatusConflict, "resource.conflict", "A conflicting operation is already active")
- case errors.Is(err, service.ErrInsufficientRole):
- writeAPIError(c, http.StatusForbidden, "authorization.insufficient_role", "Insufficient workspace role")
- case errors.Is(err, service.ErrOnboardingRequired):
- writeAPIError(c, http.StatusForbidden, "account.onboarding_required", "Complete account registration")
- case errors.Is(err, scm.ErrInvalidRemote), errors.Is(err, scm.ErrUnsafeAddress), errors.Is(err, scm.ErrUnknownProvider), strings.Contains(err.Error(), "required"):
- writeAPIError(c, http.StatusBadRequest, fallback, "Request validation failed")
- default:
- writeAPIError(c, http.StatusInternalServerError, fallback, "Unable to complete request")
- }
-}
diff --git a/internal/controller/workspace.go b/internal/controller/workspace.go
deleted file mode 100644
index 06069a4..0000000
--- a/internal/controller/workspace.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package controller
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/service"
-)
-
-type WorkspaceController struct{ workspaces *service.WorkspaceService }
-
-func NewWorkspaceController(workspaces *service.WorkspaceService) *WorkspaceController {
- return &WorkspaceController{workspaces: workspaces}
-}
-
-func (controller *WorkspaceController) Register(api *gin.RouterGroup) {
- api.POST("/workspaces", controller.create)
- api.PATCH("/workspaces/:workspaceID", controller.update)
- api.GET("/workspaces/:workspaceID/members", controller.members)
- api.PUT("/workspaces/:workspaceID/members/:userID", controller.setMember)
- api.DELETE("/workspaces/:workspaceID/members/:userID", controller.removeMember)
-}
-
-func (controller *WorkspaceController) create(c *gin.Context) {
- principal, ok := identityctx.PrincipalFromContext(c.Request.Context())
- if !ok {
- writeAPIError(c, http.StatusUnauthorized, "identity.missing", "Authentication required")
- return
- }
- var request struct {
- Name string `json:"name"`
- }
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- workspace, err := controller.workspaces.Create(c.Request.Context(), principal, request.Name)
- if err != nil {
- writeServiceError(c, err, "workspace.create_failed")
- return
- }
- c.JSON(http.StatusCreated, workspace)
-}
-
-func (controller *WorkspaceController) update(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- var request struct {
- Name string `json:"name"`
- }
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- workspace, err := controller.workspaces.Update(c.Request.Context(), principal, workspaceID, request.Name)
- if err != nil {
- writeServiceError(c, err, "workspace.update_failed")
- return
- }
- c.JSON(http.StatusOK, workspace)
-}
-
-func (controller *WorkspaceController) members(c *gin.Context) {
- principal, workspaceID, ok := requestScope(c)
- if !ok {
- return
- }
- members, err := controller.workspaces.Members(c.Request.Context(), principal, workspaceID)
- if err != nil {
- writeServiceError(c, err, "workspace.members_failed")
- return
- }
- items, nextCursor, err := paginate(members, c.Query("cursor"), c.Query("pageSize"), func(value model.WorkspaceMember) pageKey {
- return pageKey{time: value.CreatedAt, id: value.UserID}
- })
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "pagination.invalid", "Invalid pagination parameters")
- return
- }
- c.JSON(http.StatusOK, gin.H{"items": items, "nextCursor": nextCursor})
-}
-
-func (controller *WorkspaceController) setMember(c *gin.Context) {
- principal, workspaceID, userID, ok := memberScope(c)
- if !ok {
- return
- }
- var request struct {
- Role string `json:"role"`
- }
- if err := decodeJSON(c, &request); err != nil {
- writeAPIError(c, http.StatusBadRequest, "request.invalid_json", "Invalid request body")
- return
- }
- if err := controller.workspaces.SetMember(c.Request.Context(), principal, workspaceID, userID, request.Role); err != nil {
- writeServiceError(c, err, "workspace.member_failed")
- return
- }
- c.Status(http.StatusNoContent)
-}
-
-func (controller *WorkspaceController) removeMember(c *gin.Context) {
- principal, workspaceID, userID, ok := memberScope(c)
- if !ok {
- return
- }
- if err := controller.workspaces.RemoveMember(c.Request.Context(), principal, workspaceID, userID); err != nil {
- writeServiceError(c, err, "workspace.member_failed")
- return
- }
- c.Status(http.StatusNoContent)
-}
-
-func memberScope(c *gin.Context) (principal model.Principal, workspaceID, userID uuid.UUID, ok bool) {
- principal, workspaceID, ok = requestScope(c)
- if !ok {
- return principal, workspaceID, uuid.Nil, false
- }
- userID, err := uuid.Parse(c.Param("userID"))
- if err != nil {
- writeAPIError(c, http.StatusBadRequest, "user.invalid_id", "Invalid user ID")
- return principal, workspaceID, uuid.Nil, false
- }
- return principal, workspaceID, userID, true
-}
diff --git a/internal/data/pagecursor/pagecursor.go b/internal/data/pagecursor/pagecursor.go
new file mode 100644
index 0000000..a93a765
--- /dev/null
+++ b/internal/data/pagecursor/pagecursor.go
@@ -0,0 +1,15 @@
+package pagecursor
+
+import (
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func SQL(request pagination.Request) (pgtype.Timestamptz, uuid.NullUUID) {
+ if request.After == nil {
+ return pgtype.Timestamptz{}, uuid.NullUUID{}
+ }
+
+ return pgtype.Timestamptz{Time: request.After.Time, Valid: true}, uuid.NullUUID{UUID: request.After.ID, Valid: true}
+}
diff --git a/internal/data/sqlc/analysis.sql.go b/internal/data/sqlc/analysis.sql.go
new file mode 100644
index 0000000..918bc3b
--- /dev/null
+++ b/internal/data/sqlc/analysis.sql.go
@@ -0,0 +1,994 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: analysis.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const advanceAnalysisProfile = `-- name: AdvanceAnalysisProfile :one
+UPDATE analysis_profiles
+SET name=$3, current_version=current_version+1, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND current_version=$4 AND archived_at IS NULL
+RETURNING id, workspace_id, name, current_version, archived_at, created_by, created_at, updated_at
+`
+
+type AdvanceAnalysisProfileParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ CurrentVersion int32 `json:"current_version"`
+}
+
+func (q *Queries) AdvanceAnalysisProfile(ctx context.Context, arg AdvanceAnalysisProfileParams) (AnalysisProfile, error) {
+ row := q.db.QueryRow(ctx, advanceAnalysisProfile,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.Name,
+ arg.CurrentVersion,
+ )
+ var i AnalysisProfile
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.CurrentVersion,
+ &i.ArchivedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const analysisClock = `-- name: AnalysisClock :one
+SELECT clock_timestamp()::timestamptz AS current_time
+`
+
+func (q *Queries) AnalysisClock(ctx context.Context) (pgtype.Timestamptz, error) {
+ row := q.db.QueryRow(ctx, analysisClock)
+ var current_time pgtype.Timestamptz
+ err := row.Scan(¤t_time)
+ return current_time, err
+}
+
+const archiveAnalysisProfile = `-- name: ArchiveAnalysisProfile :execrows
+UPDATE analysis_profiles SET archived_at=now(), updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL
+`
+
+type ArchiveAnalysisProfileParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) ArchiveAnalysisProfile(ctx context.Context, arg ArchiveAnalysisProfileParams) (int64, error) {
+ result, err := q.db.Exec(ctx, archiveAnalysisProfile, arg.ID, arg.WorkspaceID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const cancelAnalysisRun = `-- name: CancelAnalysisRun :one
+UPDATE analysis_runs SET status='cancelled', stage='cancelled', retryable=true, finished_at=now()
+WHERE id=$1 AND workspace_id=$2 AND status IN ('queued','running')
+RETURNING workflow_run_id
+`
+
+type CancelAnalysisRunParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) CancelAnalysisRun(ctx context.Context, arg CancelAnalysisRunParams) (pgtype.Text, error) {
+ row := q.db.QueryRow(ctx, cancelAnalysisRun, arg.ID, arg.WorkspaceID)
+ var workflow_run_id pgtype.Text
+ err := row.Scan(&workflow_run_id)
+ return workflow_run_id, err
+}
+
+const countActiveAnalysisRuns = `-- name: CountActiveAnalysisRuns :one
+SELECT count(*) FROM analysis_runs WHERE workspace_id=$1 AND status IN ('queued','running')
+`
+
+func (q *Queries) CountActiveAnalysisRuns(ctx context.Context, workspaceID uuid.UUID) (int64, error) {
+ row := q.db.QueryRow(ctx, countActiveAnalysisRuns, workspaceID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const createAnalysisProfile = `-- name: CreateAnalysisProfile :one
+INSERT INTO analysis_profiles (id, workspace_id, name, current_version, created_by)
+VALUES ($1,$2,$3,1,$4) RETURNING id, workspace_id, name, current_version, archived_at, created_by, created_at, updated_at
+`
+
+type CreateAnalysisProfileParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ CreatedBy uuid.UUID `json:"created_by"`
+}
+
+func (q *Queries) CreateAnalysisProfile(ctx context.Context, arg CreateAnalysisProfileParams) (AnalysisProfile, error) {
+ row := q.db.QueryRow(ctx, createAnalysisProfile,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.Name,
+ arg.CreatedBy,
+ )
+ var i AnalysisProfile
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.CurrentVersion,
+ &i.ArchivedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const createAnalysisProfileVersion = `-- name: CreateAnalysisProfileVersion :one
+INSERT INTO analysis_profile_versions (id, workspace_id, profile_id, version, dimension_key, definition, created_by)
+VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id, workspace_id, profile_id, version, dimension_key, definition, created_by, created_at
+`
+
+type CreateAnalysisProfileVersionParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ Version int32 `json:"version"`
+ DimensionKey string `json:"dimension_key"`
+ Definition []byte `json:"definition"`
+ CreatedBy uuid.UUID `json:"created_by"`
+}
+
+func (q *Queries) CreateAnalysisProfileVersion(ctx context.Context, arg CreateAnalysisProfileVersionParams) (AnalysisProfileVersion, error) {
+ row := q.db.QueryRow(ctx, createAnalysisProfileVersion,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ProfileID,
+ arg.Version,
+ arg.DimensionKey,
+ arg.Definition,
+ arg.CreatedBy,
+ )
+ var i AnalysisProfileVersion
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProfileID,
+ &i.Version,
+ &i.DimensionKey,
+ &i.Definition,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const createAnalysisReport = `-- name: CreateAnalysisReport :one
+INSERT INTO analysis_reports (
+ id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha,
+ source_ref, commit_author_name, commit_authored_at, commit_title,
+ dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment,
+ started_at, finished_at, duration_ms, result, raw_artifact
+) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
+RETURNING id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, source_ref, commit_author_name, commit_authored_at, commit_title, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, started_at, finished_at, duration_ms, result, raw_artifact, created_at
+`
+
+type CreateAnalysisReportParams struct {
+ ID uuid.UUID `json:"id"`
+ AnalysisRunID uuid.UUID `json:"analysis_run_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ SnapshotID uuid.UUID `json:"snapshot_id"`
+ CommitSha string `json:"commit_sha"`
+ SourceRef string `json:"source_ref"`
+ CommitAuthorName pgtype.Text `json:"commit_author_name"`
+ CommitAuthoredAt pgtype.Timestamptz `json:"commit_authored_at"`
+ CommitTitle pgtype.Text `json:"commit_title"`
+ DimensionKey string `json:"dimension_key"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ ProfileVersion string `json:"profile_version"`
+ ProfileSnapshot []byte `json:"profile_snapshot"`
+ AnalyzerVersion string `json:"analyzer_version"`
+ ExecutionEnvironment string `json:"execution_environment"`
+ StartedAt pgtype.Timestamptz `json:"started_at"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+ DurationMs int64 `json:"duration_ms"`
+ Result []byte `json:"result"`
+ RawArtifact []byte `json:"raw_artifact"`
+}
+
+func (q *Queries) CreateAnalysisReport(ctx context.Context, arg CreateAnalysisReportParams) (AnalysisReport, error) {
+ row := q.db.QueryRow(ctx, createAnalysisReport,
+ arg.ID,
+ arg.AnalysisRunID,
+ arg.WorkspaceID,
+ arg.RepositoryID,
+ arg.SnapshotID,
+ arg.CommitSha,
+ arg.SourceRef,
+ arg.CommitAuthorName,
+ arg.CommitAuthoredAt,
+ arg.CommitTitle,
+ arg.DimensionKey,
+ arg.ProfileID,
+ arg.ProfileVersion,
+ arg.ProfileSnapshot,
+ arg.AnalyzerVersion,
+ arg.ExecutionEnvironment,
+ arg.StartedAt,
+ arg.FinishedAt,
+ arg.DurationMs,
+ arg.Result,
+ arg.RawArtifact,
+ )
+ var i AnalysisReport
+ err := row.Scan(
+ &i.ID,
+ &i.AnalysisRunID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.CommitAuthorName,
+ &i.CommitAuthoredAt,
+ &i.CommitTitle,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.ExecutionEnvironment,
+ &i.StartedAt,
+ &i.FinishedAt,
+ &i.DurationMs,
+ &i.Result,
+ &i.RawArtifact,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const createAnalysisRun = `-- name: CreateAnalysisRun :one
+INSERT INTO analysis_runs (
+ id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by,
+ dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status
+)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'queued')
+ON CONFLICT (workspace_id, idempotency_key, attempt) DO NOTHING
+RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at
+`
+
+type CreateAnalysisRunParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ SnapshotID uuid.UUID `json:"snapshot_id"`
+ CommitSha string `json:"commit_sha"`
+ RequestedBy uuid.UUID `json:"requested_by"`
+ DimensionKey string `json:"dimension_key"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ ProfileVersion string `json:"profile_version"`
+ ProfileSnapshot []byte `json:"profile_snapshot"`
+ AnalyzerVersion string `json:"analyzer_version"`
+ IdempotencyKey string `json:"idempotency_key"`
+ Attempt int32 `json:"attempt"`
+ RerunOf uuid.NullUUID `json:"rerun_of"`
+}
+
+func (q *Queries) CreateAnalysisRun(ctx context.Context, arg CreateAnalysisRunParams) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, createAnalysisRun,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.RepositoryID,
+ arg.SnapshotID,
+ arg.CommitSha,
+ arg.RequestedBy,
+ arg.DimensionKey,
+ arg.ProfileID,
+ arg.ProfileVersion,
+ arg.ProfileSnapshot,
+ arg.AnalyzerVersion,
+ arg.IdempotencyKey,
+ arg.Attempt,
+ arg.RerunOf,
+ )
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const failAnalysisRun = `-- name: FailAnalysisRun :one
+UPDATE analysis_runs
+SET status='failed', stage=$2, failed_stage=$2, error_code=$3, error_message=$4,
+ retryable=$5, finished_at=now()
+WHERE id=$1 AND status IN ('queued','running') RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at
+`
+
+type FailAnalysisRunParams struct {
+ ID uuid.UUID `json:"id"`
+ Stage string `json:"stage"`
+ ErrorCode pgtype.Text `json:"error_code"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+ Retryable bool `json:"retryable"`
+}
+
+func (q *Queries) FailAnalysisRun(ctx context.Context, arg FailAnalysisRunParams) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, failAnalysisRun,
+ arg.ID,
+ arg.Stage,
+ arg.ErrorCode,
+ arg.ErrorMessage,
+ arg.Retryable,
+ )
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const finishAnalysisRun = `-- name: FinishAnalysisRun :one
+UPDATE analysis_runs
+SET status='succeeded', report_id=$2, failed_stage=NULL, error_code=NULL,
+ error_message=NULL, retryable=false, stage='complete', finished_at=$3
+WHERE id=$1 AND status='running' RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at
+`
+
+type FinishAnalysisRunParams struct {
+ ID uuid.UUID `json:"id"`
+ ReportID uuid.NullUUID `json:"report_id"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+}
+
+func (q *Queries) FinishAnalysisRun(ctx context.Context, arg FinishAnalysisRunParams) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, finishAnalysisRun, arg.ID, arg.ReportID, arg.FinishedAt)
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getAnalysisProfile = `-- name: GetAnalysisProfile :one
+SELECT p.id, p.workspace_id, p.name, p.current_version, p.archived_at, p.created_by, p.created_at, p.updated_at, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at
+FROM analysis_profiles p
+JOIN analysis_profile_versions v
+ ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version
+WHERE p.id=$1 AND p.workspace_id=$2 AND p.archived_at IS NULL
+`
+
+type GetAnalysisProfileParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+type GetAnalysisProfileRow struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ CurrentVersion int32 `json:"current_version"`
+ ArchivedAt pgtype.Timestamptz `json:"archived_at"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ VersionID uuid.UUID `json:"version_id"`
+ DimensionKey string `json:"dimension_key"`
+ Definition []byte `json:"definition"`
+ VersionCreatedAt pgtype.Timestamptz `json:"version_created_at"`
+}
+
+func (q *Queries) GetAnalysisProfile(ctx context.Context, arg GetAnalysisProfileParams) (GetAnalysisProfileRow, error) {
+ row := q.db.QueryRow(ctx, getAnalysisProfile, arg.ID, arg.WorkspaceID)
+ var i GetAnalysisProfileRow
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.CurrentVersion,
+ &i.ArchivedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.VersionID,
+ &i.DimensionKey,
+ &i.Definition,
+ &i.VersionCreatedAt,
+ )
+ return i, err
+}
+
+const getAnalysisReport = `-- name: GetAnalysisReport :one
+SELECT id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, source_ref, commit_author_name, commit_authored_at, commit_title, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, started_at, finished_at, duration_ms, result, raw_artifact, created_at FROM analysis_reports WHERE id=$1 AND workspace_id=$2
+`
+
+type GetAnalysisReportParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetAnalysisReport(ctx context.Context, arg GetAnalysisReportParams) (AnalysisReport, error) {
+ row := q.db.QueryRow(ctx, getAnalysisReport, arg.ID, arg.WorkspaceID)
+ var i AnalysisReport
+ err := row.Scan(
+ &i.ID,
+ &i.AnalysisRunID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.CommitAuthorName,
+ &i.CommitAuthoredAt,
+ &i.CommitTitle,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.ExecutionEnvironment,
+ &i.StartedAt,
+ &i.FinishedAt,
+ &i.DurationMs,
+ &i.Result,
+ &i.RawArtifact,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getAnalysisReportByRun = `-- name: GetAnalysisReportByRun :one
+SELECT id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha, source_ref, commit_author_name, commit_authored_at, commit_title, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment, started_at, finished_at, duration_ms, result, raw_artifact, created_at FROM analysis_reports WHERE analysis_run_id=$1
+`
+
+func (q *Queries) GetAnalysisReportByRun(ctx context.Context, analysisRunID uuid.UUID) (AnalysisReport, error) {
+ row := q.db.QueryRow(ctx, getAnalysisReportByRun, analysisRunID)
+ var i AnalysisReport
+ err := row.Scan(
+ &i.ID,
+ &i.AnalysisRunID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.CommitAuthorName,
+ &i.CommitAuthoredAt,
+ &i.CommitTitle,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.ExecutionEnvironment,
+ &i.StartedAt,
+ &i.FinishedAt,
+ &i.DurationMs,
+ &i.Result,
+ &i.RawArtifact,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getAnalysisRun = `-- name: GetAnalysisRun :one
+SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs WHERE id=$1 AND workspace_id=$2
+`
+
+type GetAnalysisRunParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetAnalysisRun(ctx context.Context, arg GetAnalysisRunParams) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, getAnalysisRun, arg.ID, arg.WorkspaceID)
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getAnalysisRunByAttempt = `-- name: GetAnalysisRunByAttempt :one
+SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs
+WHERE workspace_id=$1 AND idempotency_key=$2 AND attempt=$3
+`
+
+type GetAnalysisRunByAttemptParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ IdempotencyKey string `json:"idempotency_key"`
+ Attempt int32 `json:"attempt"`
+}
+
+func (q *Queries) GetAnalysisRunByAttempt(ctx context.Context, arg GetAnalysisRunByAttemptParams) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, getAnalysisRunByAttempt, arg.WorkspaceID, arg.IdempotencyKey, arg.Attempt)
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getAnalysisRunWork = `-- name: GetAnalysisRunWork :one
+SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs WHERE id=$1
+`
+
+func (q *Queries) GetAnalysisRunWork(ctx context.Context, id uuid.UUID) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, getAnalysisRunWork, id)
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getDefaultAnalysisProfile = `-- name: GetDefaultAnalysisProfile :one
+SELECT p.id, p.workspace_id, p.name, p.current_version, p.archived_at, p.created_by, p.created_at, p.updated_at, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at
+FROM analysis_profiles p
+JOIN analysis_profile_versions v
+ ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version
+WHERE p.workspace_id=$1 AND p.archived_at IS NULL
+ORDER BY p.created_at, p.id
+LIMIT 1
+`
+
+type GetDefaultAnalysisProfileRow struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ CurrentVersion int32 `json:"current_version"`
+ ArchivedAt pgtype.Timestamptz `json:"archived_at"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ VersionID uuid.UUID `json:"version_id"`
+ DimensionKey string `json:"dimension_key"`
+ Definition []byte `json:"definition"`
+ VersionCreatedAt pgtype.Timestamptz `json:"version_created_at"`
+}
+
+func (q *Queries) GetDefaultAnalysisProfile(ctx context.Context, workspaceID uuid.UUID) (GetDefaultAnalysisProfileRow, error) {
+ row := q.db.QueryRow(ctx, getDefaultAnalysisProfile, workspaceID)
+ var i GetDefaultAnalysisProfileRow
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.CurrentVersion,
+ &i.ArchivedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.VersionID,
+ &i.DimensionKey,
+ &i.Definition,
+ &i.VersionCreatedAt,
+ )
+ return i, err
+}
+
+const listAnalysisProfiles = `-- name: ListAnalysisProfiles :many
+SELECT p.id, p.workspace_id, p.name, p.current_version, p.archived_at, p.created_by, p.created_at, p.updated_at, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at
+FROM analysis_profiles p
+JOIN analysis_profile_versions v
+ ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version
+WHERE p.workspace_id=$1 AND p.archived_at IS NULL
+ AND (
+ $2::timestamptz IS NULL
+ OR (p.created_at, p.id) < ($2, $3::uuid)
+ )
+ORDER BY p.created_at DESC, p.id DESC
+LIMIT $4
+`
+
+type ListAnalysisProfilesParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+type ListAnalysisProfilesRow struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ CurrentVersion int32 `json:"current_version"`
+ ArchivedAt pgtype.Timestamptz `json:"archived_at"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ VersionID uuid.UUID `json:"version_id"`
+ DimensionKey string `json:"dimension_key"`
+ Definition []byte `json:"definition"`
+ VersionCreatedAt pgtype.Timestamptz `json:"version_created_at"`
+}
+
+func (q *Queries) ListAnalysisProfiles(ctx context.Context, arg ListAnalysisProfilesParams) ([]ListAnalysisProfilesRow, error) {
+ rows, err := q.db.Query(ctx, listAnalysisProfiles,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListAnalysisProfilesRow{}
+ for rows.Next() {
+ var i ListAnalysisProfilesRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Name,
+ &i.CurrentVersion,
+ &i.ArchivedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.VersionID,
+ &i.DimensionKey,
+ &i.Definition,
+ &i.VersionCreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listAnalysisRuns = `-- name: ListAnalysisRuns :many
+SELECT id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at FROM analysis_runs
+WHERE repository_id=$1
+ AND workspace_id=$2
+ AND (
+ $3::timestamptz IS NULL
+ OR (created_at, id) < ($3, $4::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT $5
+`
+
+type ListAnalysisRunsParams struct {
+ RepositoryID uuid.UUID `json:"repository_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ListAnalysisRuns(ctx context.Context, arg ListAnalysisRunsParams) ([]AnalysisRun, error) {
+ rows, err := q.db.Query(ctx, listAnalysisRuns,
+ arg.RepositoryID,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []AnalysisRun{}
+ for rows.Next() {
+ var i AnalysisRun
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const lockActiveAnalysisProfiles = `-- name: LockActiveAnalysisProfiles :many
+SELECT id FROM analysis_profiles
+WHERE workspace_id=$1 AND archived_at IS NULL
+ORDER BY id
+FOR UPDATE
+`
+
+func (q *Queries) LockActiveAnalysisProfiles(ctx context.Context, workspaceID uuid.UUID) ([]uuid.UUID, error) {
+ rows, err := q.db.Query(ctx, lockActiveAnalysisProfiles, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []uuid.UUID{}
+ for rows.Next() {
+ var id uuid.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ items = append(items, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const lockAnalysisSnapshot = `-- name: LockAnalysisSnapshot :one
+SELECT snapshot.source_state
+FROM commit_snapshots AS snapshot
+JOIN repositories AS repository ON repository.id = snapshot.repository_id
+WHERE snapshot.id = $1
+ AND snapshot.repository_id = $2
+ AND repository.workspace_id = $3
+FOR UPDATE OF snapshot
+`
+
+type LockAnalysisSnapshotParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) LockAnalysisSnapshot(ctx context.Context, arg LockAnalysisSnapshotParams) (string, error) {
+ row := q.db.QueryRow(ctx, lockAnalysisSnapshot, arg.ID, arg.RepositoryID, arg.WorkspaceID)
+ var source_state string
+ err := row.Scan(&source_state)
+ return source_state, err
+}
+
+const setAnalysisRunStage = `-- name: SetAnalysisRunStage :execrows
+UPDATE analysis_runs SET stage=$2
+WHERE id=$1 AND status='running'
+`
+
+type SetAnalysisRunStageParams struct {
+ ID uuid.UUID `json:"id"`
+ Stage string `json:"stage"`
+}
+
+func (q *Queries) SetAnalysisRunStage(ctx context.Context, arg SetAnalysisRunStageParams) (int64, error) {
+ result, err := q.db.Exec(ctx, setAnalysisRunStage, arg.ID, arg.Stage)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const setAnalysisWorkflowID = `-- name: SetAnalysisWorkflowID :exec
+UPDATE analysis_runs SET workflow_run_id=$2 WHERE id=$1
+`
+
+type SetAnalysisWorkflowIDParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+}
+
+func (q *Queries) SetAnalysisWorkflowID(ctx context.Context, arg SetAnalysisWorkflowIDParams) error {
+ _, err := q.db.Exec(ctx, setAnalysisWorkflowID, arg.ID, arg.WorkflowRunID)
+ return err
+}
+
+const startAnalysisRun = `-- name: StartAnalysisRun :one
+UPDATE analysis_runs SET status='running', stage='authorize', started_at=now()
+WHERE id=$1 AND status='queued' RETURNING id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by, dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status, stage, report_id, workflow_run_id, failed_stage, error_code, error_message, retryable, created_at, started_at, finished_at
+`
+
+func (q *Queries) StartAnalysisRun(ctx context.Context, id uuid.UUID) (AnalysisRun, error) {
+ row := q.db.QueryRow(ctx, startAnalysisRun, id)
+ var i AnalysisRun
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.RepositoryID,
+ &i.SnapshotID,
+ &i.CommitSha,
+ &i.RequestedBy,
+ &i.DimensionKey,
+ &i.ProfileID,
+ &i.ProfileVersion,
+ &i.ProfileSnapshot,
+ &i.AnalyzerVersion,
+ &i.IdempotencyKey,
+ &i.Attempt,
+ &i.RerunOf,
+ &i.Status,
+ &i.Stage,
+ &i.ReportID,
+ &i.WorkflowRunID,
+ &i.FailedStage,
+ &i.ErrorCode,
+ &i.ErrorMessage,
+ &i.Retryable,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
diff --git a/internal/data/sqlc/audit.sql.go b/internal/data/sqlc/audit.sql.go
new file mode 100644
index 0000000..8137bca
--- /dev/null
+++ b/internal/data/sqlc/audit.sql.go
@@ -0,0 +1,55 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: audit.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+const createAuditEvent = `-- name: CreateAuditEvent :exec
+INSERT INTO audit_events (
+ id,
+ workspace_id,
+ actor_user_id,
+ action,
+ resource_type,
+ resource_id,
+ metadata
+) VALUES (
+ $1,
+ $2,
+ $3,
+ $4,
+ $5,
+ $6,
+ $7
+)
+`
+
+type CreateAuditEventParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.NullUUID `json:"workspace_id"`
+ ActorUserID uuid.NullUUID `json:"actor_user_id"`
+ Action string `json:"action"`
+ ResourceType string `json:"resource_type"`
+ ResourceID uuid.NullUUID `json:"resource_id"`
+ Metadata []byte `json:"metadata"`
+}
+
+func (q *Queries) CreateAuditEvent(ctx context.Context, arg CreateAuditEventParams) error {
+ _, err := q.db.Exec(ctx, createAuditEvent,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ActorUserID,
+ arg.Action,
+ arg.ResourceType,
+ arg.ResourceID,
+ arg.Metadata,
+ )
+ return err
+}
diff --git a/internal/data/sqlc/channel_commands.sql.go b/internal/data/sqlc/channel_commands.sql.go
new file mode 100644
index 0000000..fd6ae8b
--- /dev/null
+++ b/internal/data/sqlc/channel_commands.sql.go
@@ -0,0 +1,247 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: channel_commands.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const checkActiveChannelVersion = `-- name: CheckActiveChannelVersion :one
+SELECT config_version FROM channels
+WHERE id=$1 AND workspace_id=$2 AND enabled AND config_version=$3
+`
+
+type CheckActiveChannelVersionParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) CheckActiveChannelVersion(ctx context.Context, arg CheckActiveChannelVersionParams) (int64, error) {
+ row := q.db.QueryRow(ctx, checkActiveChannelVersion, arg.ID, arg.WorkspaceID, arg.ConfigVersion)
+ var config_version int64
+ err := row.Scan(&config_version)
+ return config_version, err
+}
+
+const consumeChannelIdentityLink = `-- name: ConsumeChannelIdentityLink :execrows
+UPDATE channel_identity_links l SET consumed_at=now()
+FROM channels c
+WHERE l.id=$1
+ AND l.consumed_at IS NULL
+ AND l.expires_at > now()
+ AND c.id=l.channel_id
+ AND c.enabled
+ AND c.config_version=l.channel_version
+`
+
+func (q *Queries) ConsumeChannelIdentityLink(ctx context.Context, id uuid.UUID) (int64, error) {
+ result, err := q.db.Exec(ctx, consumeChannelIdentityLink, id)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const createChannelIdentityLink = `-- name: CreateChannelIdentityLink :one
+INSERT INTO channel_identity_links (
+ id, workspace_id, channel_id, channel_version, sender_canonical_id, token_hash, expires_at
+)
+SELECT $1, $2, $3,
+ $4, $5,
+ $6, $7
+FROM channels
+WHERE id=$3
+ AND workspace_id=$2
+ AND enabled
+ AND config_version=$4
+RETURNING id, workspace_id, channel_id, channel_version, sender_canonical_id, token_hash, expires_at, consumed_at, created_at
+`
+
+type CreateChannelIdentityLinkParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ChannelVersion int64 `json:"channel_version"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ TokenHash []byte `json:"token_hash"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+}
+
+func (q *Queries) CreateChannelIdentityLink(ctx context.Context, arg CreateChannelIdentityLinkParams) (ChannelIdentityLink, error) {
+ row := q.db.QueryRow(ctx, createChannelIdentityLink,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ChannelID,
+ arg.ChannelVersion,
+ arg.SenderCanonicalID,
+ arg.TokenHash,
+ arg.ExpiresAt,
+ )
+ var i ChannelIdentityLink
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ChannelID,
+ &i.ChannelVersion,
+ &i.SenderCanonicalID,
+ &i.TokenHash,
+ &i.ExpiresAt,
+ &i.ConsumedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const deleteConversationBinding = `-- name: DeleteConversationBinding :execrows
+DELETE FROM conversation_bindings WHERE conversation_id=$1
+`
+
+func (q *Queries) DeleteConversationBinding(ctx context.Context, conversationID uuid.UUID) (int64, error) {
+ result, err := q.db.Exec(ctx, deleteConversationBinding, conversationID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const getChannelExternalIdentity = `-- name: GetChannelExternalIdentity :one
+SELECT user_id FROM channel_external_identities
+WHERE channel_id=$1 AND sender_canonical_id=$2
+`
+
+type GetChannelExternalIdentityParams struct {
+ ChannelID uuid.UUID `json:"channel_id"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+}
+
+func (q *Queries) GetChannelExternalIdentity(ctx context.Context, arg GetChannelExternalIdentityParams) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, getChannelExternalIdentity, arg.ChannelID, arg.SenderCanonicalID)
+ var user_id uuid.UUID
+ err := row.Scan(&user_id)
+ return user_id, err
+}
+
+const getChannelIdentityLink = `-- name: GetChannelIdentityLink :one
+SELECT l.id, l.workspace_id, l.channel_id, l.channel_version, l.sender_canonical_id, l.token_hash, l.expires_at, l.consumed_at, l.created_at FROM channel_identity_links l
+JOIN channels c ON c.id=l.channel_id
+WHERE l.token_hash=$1
+ AND l.consumed_at IS NULL
+ AND l.expires_at > now()
+ AND c.enabled
+ AND c.config_version=l.channel_version
+`
+
+func (q *Queries) GetChannelIdentityLink(ctx context.Context, tokenHash []byte) (ChannelIdentityLink, error) {
+ row := q.db.QueryRow(ctx, getChannelIdentityLink, tokenHash)
+ var i ChannelIdentityLink
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ChannelID,
+ &i.ChannelVersion,
+ &i.SenderCanonicalID,
+ &i.TokenHash,
+ &i.ExpiresAt,
+ &i.ConsumedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getConversationBinding = `-- name: GetConversationBinding :one
+SELECT repository_id FROM conversation_bindings WHERE conversation_id=$1
+`
+
+func (q *Queries) GetConversationBinding(ctx context.Context, conversationID uuid.UUID) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, getConversationBinding, conversationID)
+ var repository_id uuid.UUID
+ err := row.Scan(&repository_id)
+ return repository_id, err
+}
+
+const getConversationIDByExternal = `-- name: GetConversationIDByExternal :one
+SELECT conversation.id FROM conversations AS conversation
+JOIN channels AS channel ON channel.id=conversation.channel_id
+WHERE conversation.channel_id=$1
+ AND conversation.external_id=$2
+ AND channel.workspace_id=$3
+ AND channel.enabled
+ AND channel.config_version=$4
+`
+
+type GetConversationIDByExternalParams struct {
+ ChannelID uuid.UUID `json:"channel_id"`
+ ExternalID string `json:"external_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelVersion int64 `json:"channel_version"`
+}
+
+func (q *Queries) GetConversationIDByExternal(ctx context.Context, arg GetConversationIDByExternalParams) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, getConversationIDByExternal,
+ arg.ChannelID,
+ arg.ExternalID,
+ arg.WorkspaceID,
+ arg.ChannelVersion,
+ )
+ var id uuid.UUID
+ err := row.Scan(&id)
+ return id, err
+}
+
+const upsertChannelExternalIdentity = `-- name: UpsertChannelExternalIdentity :exec
+INSERT INTO channel_external_identities (
+ id, workspace_id, channel_id, sender_canonical_id, user_id
+) VALUES ($1,$2,$3,$4,$5)
+ON CONFLICT (channel_id, sender_canonical_id) DO UPDATE
+SET user_id=excluded.user_id
+`
+
+type UpsertChannelExternalIdentityParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) UpsertChannelExternalIdentity(ctx context.Context, arg UpsertChannelExternalIdentityParams) error {
+ _, err := q.db.Exec(ctx, upsertChannelExternalIdentity,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ChannelID,
+ arg.SenderCanonicalID,
+ arg.UserID,
+ )
+ return err
+}
+
+const upsertConversationBinding = `-- name: UpsertConversationBinding :exec
+INSERT INTO conversation_bindings (conversation_id, workspace_id, repository_id, bound_by)
+VALUES ($1,$2,$3,$4)
+ON CONFLICT (conversation_id) DO UPDATE
+SET repository_id=excluded.repository_id, bound_by=excluded.bound_by, updated_at=now()
+`
+
+type UpsertConversationBindingParams struct {
+ ConversationID uuid.UUID `json:"conversation_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ BoundBy uuid.UUID `json:"bound_by"`
+}
+
+func (q *Queries) UpsertConversationBinding(ctx context.Context, arg UpsertConversationBindingParams) error {
+ _, err := q.db.Exec(ctx, upsertConversationBinding,
+ arg.ConversationID,
+ arg.WorkspaceID,
+ arg.RepositoryID,
+ arg.BoundBy,
+ )
+ return err
+}
diff --git a/internal/data/sqlc/channels.sql.go b/internal/data/sqlc/channels.sql.go
new file mode 100644
index 0000000..f9c4261
--- /dev/null
+++ b/internal/data/sqlc/channels.sql.go
@@ -0,0 +1,759 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: channels.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const createChannel = `-- name: CreateChannel :one
+INSERT INTO channels (id, workspace_id, type, name, enabled, secret_ciphertext, secret_nonce, key_version, config)
+VALUES ($1,$2,$3,$4,false,$5,$6,$7,$8) RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at
+`
+
+type CreateChannelParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ SecretCiphertext []byte `json:"secret_ciphertext"`
+ SecretNonce []byte `json:"secret_nonce"`
+ KeyVersion pgtype.Int4 `json:"key_version"`
+ Config []byte `json:"config"`
+}
+
+func (q *Queries) CreateChannel(ctx context.Context, arg CreateChannelParams) (Channel, error) {
+ row := q.db.QueryRow(ctx, createChannel,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.Type,
+ arg.Name,
+ arg.SecretCiphertext,
+ arg.SecretNonce,
+ arg.KeyVersion,
+ arg.Config,
+ )
+ var i Channel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const createChannelSubscription = `-- name: CreateChannelSubscription :exec
+INSERT INTO channel_subscriptions (id, workspace_id, channel_id, event_type)
+VALUES ($1,$2,$3,$4)
+`
+
+type CreateChannelSubscriptionParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ EventType string `json:"event_type"`
+}
+
+func (q *Queries) CreateChannelSubscription(ctx context.Context, arg CreateChannelSubscriptionParams) error {
+ _, err := q.db.Exec(ctx, createChannelSubscription,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ChannelID,
+ arg.EventType,
+ )
+ return err
+}
+
+const deleteChannel = `-- name: DeleteChannel :execrows
+DELETE FROM channels WHERE id=$1 AND workspace_id=$2
+`
+
+type DeleteChannelParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) DeleteChannel(ctx context.Context, arg DeleteChannelParams) (int64, error) {
+ result, err := q.db.Exec(ctx, deleteChannel, arg.ID, arg.WorkspaceID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const deleteChannelSubscriptions = `-- name: DeleteChannelSubscriptions :exec
+DELETE FROM channel_subscriptions WHERE workspace_id=$1 AND channel_id=$2
+`
+
+type DeleteChannelSubscriptionsParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+}
+
+func (q *Queries) DeleteChannelSubscriptions(ctx context.Context, arg DeleteChannelSubscriptionsParams) error {
+ _, err := q.db.Exec(ctx, deleteChannelSubscriptions, arg.WorkspaceID, arg.ChannelID)
+ return err
+}
+
+const getChannel = `-- name: GetChannel :one
+SELECT id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at FROM channels WHERE id=$1 AND workspace_id=$2
+`
+
+type GetChannelParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetChannel(ctx context.Context, arg GetChannelParams) (Channel, error) {
+ row := q.db.QueryRow(ctx, getChannel, arg.ID, arg.WorkspaceID)
+ var i Channel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getWorkspaceOverview = `-- name: GetWorkspaceOverview :one
+SELECT
+ (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted') AS repository_count,
+ (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted' AND rr.current_snapshot_id IS NULL) AS repository_without_code_count,
+ (SELECT count(*) FROM repository_operations o JOIN repositories r ON r.id=o.repository_id WHERE r.workspace_id=$1 AND o.status='failed' AND o.created_at > now()-interval '7 days') AS recent_failed_sync_count,
+ (SELECT count(*) FROM analysis_runs ar WHERE ar.workspace_id=$1 AND ar.status IN ('queued','running')) AS active_analysis_count,
+ (SELECT count(*) FROM channels cc WHERE cc.workspace_id=$1 AND cc.enabled) AS active_channel_count
+`
+
+type GetWorkspaceOverviewRow struct {
+ RepositoryCount int64 `json:"repository_count"`
+ RepositoryWithoutCodeCount int64 `json:"repository_without_code_count"`
+ RecentFailedSyncCount int64 `json:"recent_failed_sync_count"`
+ ActiveAnalysisCount int64 `json:"active_analysis_count"`
+ ActiveChannelCount int64 `json:"active_channel_count"`
+}
+
+func (q *Queries) GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (GetWorkspaceOverviewRow, error) {
+ row := q.db.QueryRow(ctx, getWorkspaceOverview, workspaceID)
+ var i GetWorkspaceOverviewRow
+ err := row.Scan(
+ &i.RepositoryCount,
+ &i.RepositoryWithoutCodeCount,
+ &i.RecentFailedSyncCount,
+ &i.ActiveAnalysisCount,
+ &i.ActiveChannelCount,
+ )
+ return i, err
+}
+
+const insertInboundMessage = `-- name: InsertInboundMessage :execrows
+INSERT INTO messages (
+ id, workspace_id, channel_id, conversation_id, external_id,
+ sender_canonical_id, sender_display_name, content, occurred_at
+)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ON CONFLICT (channel_id, external_id) DO NOTHING
+`
+
+type InsertInboundMessageParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ConversationID uuid.UUID `json:"conversation_id"`
+ ExternalID string `json:"external_id"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ SenderDisplayName string `json:"sender_display_name"`
+ Content []byte `json:"content"`
+ OccurredAt pgtype.Timestamptz `json:"occurred_at"`
+}
+
+func (q *Queries) InsertInboundMessage(ctx context.Context, arg InsertInboundMessageParams) (int64, error) {
+ result, err := q.db.Exec(ctx, insertInboundMessage,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ChannelID,
+ arg.ConversationID,
+ arg.ExternalID,
+ arg.SenderCanonicalID,
+ arg.SenderDisplayName,
+ arg.Content,
+ arg.OccurredAt,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const listChannelSubscriptions = `-- name: ListChannelSubscriptions :many
+SELECT channel_id, event_type FROM channel_subscriptions
+WHERE workspace_id=$1
+ORDER BY channel_id, event_type
+`
+
+type ListChannelSubscriptionsRow struct {
+ ChannelID uuid.UUID `json:"channel_id"`
+ EventType string `json:"event_type"`
+}
+
+func (q *Queries) ListChannelSubscriptions(ctx context.Context, workspaceID uuid.UUID) ([]ListChannelSubscriptionsRow, error) {
+ rows, err := q.db.Query(ctx, listChannelSubscriptions, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListChannelSubscriptionsRow{}
+ for rows.Next() {
+ var i ListChannelSubscriptionsRow
+ if err := rows.Scan(&i.ChannelID, &i.EventType); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listChannels = `-- name: ListChannels :many
+SELECT id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at FROM channels
+WHERE workspace_id=$1
+ AND (
+ $2::timestamptz IS NULL
+ OR (created_at, id) < ($2, $3::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT $4
+`
+
+type ListChannelsParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ListChannels(ctx context.Context, arg ListChannelsParams) ([]Channel, error) {
+ rows, err := q.db.Query(ctx, listChannels,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Channel{}
+ for rows.Next() {
+ var i Channel
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listConversations = `-- name: ListConversations :many
+SELECT c.id, c.workspace_id, c.channel_id, c.external_id, c.type, c.title, c.created_at,
+ ch.name AS channel_name, ch.type AS channel_type
+FROM conversations c JOIN channels ch ON ch.id=c.channel_id
+WHERE c.workspace_id=$1
+ AND (
+ $2::timestamptz IS NULL
+ OR (c.created_at, c.id) < ($2, $3::uuid)
+ )
+ORDER BY c.created_at DESC, c.id DESC
+LIMIT $4
+`
+
+type ListConversationsParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+type ListConversationsRow struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ExternalID string `json:"external_id"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ ChannelName string `json:"channel_name"`
+ ChannelType string `json:"channel_type"`
+}
+
+func (q *Queries) ListConversations(ctx context.Context, arg ListConversationsParams) ([]ListConversationsRow, error) {
+ rows, err := q.db.Query(ctx, listConversations,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListConversationsRow{}
+ for rows.Next() {
+ var i ListConversationsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ChannelID,
+ &i.ExternalID,
+ &i.Type,
+ &i.Title,
+ &i.CreatedAt,
+ &i.ChannelName,
+ &i.ChannelType,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listMessages = `-- name: ListMessages :many
+SELECT m.id, m.workspace_id, m.channel_id, m.conversation_id, m.external_id, m.sender_canonical_id, m.sender_display_name, m.content, m.occurred_at, m.created_at, c.external_id AS conversation_external_id, c.type AS conversation_type,
+ ch.name AS channel_name, ch.type AS channel_type
+FROM messages m
+JOIN conversations c ON c.id=m.conversation_id
+JOIN channels ch ON ch.id=m.channel_id
+WHERE m.workspace_id=$1
+ AND ($2::uuid IS NULL OR m.channel_id=$2)
+ AND ($3::uuid IS NULL OR m.conversation_id=$3)
+ AND (
+ $4::timestamptz IS NULL
+ OR (m.occurred_at, m.id) < ($4, $5::uuid)
+ )
+ORDER BY m.occurred_at DESC, m.id DESC
+LIMIT $6
+`
+
+type ListMessagesParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.NullUUID `json:"channel_id"`
+ ConversationID uuid.NullUUID `json:"conversation_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+type ListMessagesRow struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ConversationID uuid.UUID `json:"conversation_id"`
+ ExternalID string `json:"external_id"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ SenderDisplayName string `json:"sender_display_name"`
+ Content []byte `json:"content"`
+ OccurredAt pgtype.Timestamptz `json:"occurred_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ ConversationExternalID string `json:"conversation_external_id"`
+ ConversationType string `json:"conversation_type"`
+ ChannelName string `json:"channel_name"`
+ ChannelType string `json:"channel_type"`
+}
+
+func (q *Queries) ListMessages(ctx context.Context, arg ListMessagesParams) ([]ListMessagesRow, error) {
+ rows, err := q.db.Query(ctx, listMessages,
+ arg.WorkspaceID,
+ arg.ChannelID,
+ arg.ConversationID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListMessagesRow{}
+ for rows.Next() {
+ var i ListMessagesRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ChannelID,
+ &i.ConversationID,
+ &i.ExternalID,
+ &i.SenderCanonicalID,
+ &i.SenderDisplayName,
+ &i.Content,
+ &i.OccurredAt,
+ &i.CreatedAt,
+ &i.ConversationExternalID,
+ &i.ConversationType,
+ &i.ChannelName,
+ &i.ChannelType,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listRuntimeChannels = `-- name: ListRuntimeChannels :many
+SELECT id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at FROM channels
+WHERE enabled AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND key_version IS NOT NULL
+ORDER BY created_at
+`
+
+func (q *Queries) ListRuntimeChannels(ctx context.Context) ([]Channel, error) {
+ rows, err := q.db.Query(ctx, listRuntimeChannels)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Channel{}
+ for rows.Next() {
+ var i Channel
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listSubscribedChannels = `-- name: ListSubscribedChannels :many
+SELECT c.id, c.workspace_id, c.type, c.name, c.enabled, c.runtime_status, c.secret_ciphertext, c.secret_nonce, c.key_version, c.config_version, c.config, c.last_connected_at, c.last_error_message, c.created_at, c.updated_at FROM channels c
+JOIN channel_subscriptions subscription ON subscription.channel_id=c.id AND subscription.workspace_id=c.workspace_id
+WHERE c.workspace_id=$1 AND c.enabled AND subscription.event_type=$2
+ORDER BY c.created_at
+`
+
+type ListSubscribedChannelsParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ EventType string `json:"event_type"`
+}
+
+func (q *Queries) ListSubscribedChannels(ctx context.Context, arg ListSubscribedChannelsParams) ([]Channel, error) {
+ rows, err := q.db.Query(ctx, listSubscribedChannels, arg.WorkspaceID, arg.EventType)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Channel{}
+ for rows.Next() {
+ var i Channel
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const lockChannelRuntime = `-- name: LockChannelRuntime :one
+SELECT config_version FROM channels
+WHERE id=$1 AND workspace_id=$2 AND enabled
+FOR KEY SHARE
+`
+
+type LockChannelRuntimeParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) LockChannelRuntime(ctx context.Context, arg LockChannelRuntimeParams) (int64, error) {
+ row := q.db.QueryRow(ctx, lockChannelRuntime, arg.ID, arg.WorkspaceID)
+ var config_version int64
+ err := row.Scan(&config_version)
+ return config_version, err
+}
+
+const markChannelRuntimeConnected = `-- name: MarkChannelRuntimeConnected :execrows
+UPDATE channels SET runtime_status='connected', last_connected_at=now(), last_error_message=NULL, updated_at=now()
+WHERE id=$1 AND enabled AND config_version=$2
+`
+
+type MarkChannelRuntimeConnectedParams struct {
+ ID uuid.UUID `json:"id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) MarkChannelRuntimeConnected(ctx context.Context, arg MarkChannelRuntimeConnectedParams) (int64, error) {
+ result, err := q.db.Exec(ctx, markChannelRuntimeConnected, arg.ID, arg.ConfigVersion)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const markChannelRuntimeError = `-- name: MarkChannelRuntimeError :execrows
+UPDATE channels SET runtime_status='error', last_error_message=$3, updated_at=now()
+WHERE id=$1 AND enabled AND config_version=$2
+`
+
+type MarkChannelRuntimeErrorParams struct {
+ ID uuid.UUID `json:"id"`
+ ConfigVersion int64 `json:"config_version"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+}
+
+func (q *Queries) MarkChannelRuntimeError(ctx context.Context, arg MarkChannelRuntimeErrorParams) (int64, error) {
+ result, err := q.db.Exec(ctx, markChannelRuntimeError, arg.ID, arg.ConfigVersion, arg.LastErrorMessage)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const rotateChannelCredential = `-- name: RotateChannelCredential :one
+UPDATE channels SET secret_ciphertext=$3, secret_nonce=$4, key_version=$5,
+ config_version=config_version+1,
+ runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END,
+ last_error_message=NULL, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND config_version=$6 RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at
+`
+
+type RotateChannelCredentialParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ SecretCiphertext []byte `json:"secret_ciphertext"`
+ SecretNonce []byte `json:"secret_nonce"`
+ KeyVersion pgtype.Int4 `json:"key_version"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) RotateChannelCredential(ctx context.Context, arg RotateChannelCredentialParams) (Channel, error) {
+ row := q.db.QueryRow(ctx, rotateChannelCredential,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.SecretCiphertext,
+ arg.SecretNonce,
+ arg.KeyVersion,
+ arg.ConfigVersion,
+ )
+ var i Channel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const setChannelEnabled = `-- name: SetChannelEnabled :one
+UPDATE channels SET enabled=$3, runtime_status=CASE WHEN $3 THEN 'starting' ELSE 'disabled' END,
+ last_error_message=CASE WHEN $3 THEN NULL ELSE last_error_message END, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at
+`
+
+type SetChannelEnabledParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Enabled bool `json:"enabled"`
+}
+
+func (q *Queries) SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (Channel, error) {
+ row := q.db.QueryRow(ctx, setChannelEnabled, arg.ID, arg.WorkspaceID, arg.Enabled)
+ var i Channel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const updateChannel = `-- name: UpdateChannel :one
+UPDATE channels SET name=$3, config=$4, config_version=config_version+1,
+ runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END,
+ last_error_message=NULL, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND config_version=$5 RETURNING id, workspace_id, type, name, enabled, runtime_status, secret_ciphertext, secret_nonce, key_version, config_version, config, last_connected_at, last_error_message, created_at, updated_at
+`
+
+type UpdateChannelParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ Config []byte `json:"config"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) UpdateChannel(ctx context.Context, arg UpdateChannelParams) (Channel, error) {
+ row := q.db.QueryRow(ctx, updateChannel,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.Name,
+ arg.Config,
+ arg.ConfigVersion,
+ )
+ var i Channel
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Type,
+ &i.Name,
+ &i.Enabled,
+ &i.RuntimeStatus,
+ &i.SecretCiphertext,
+ &i.SecretNonce,
+ &i.KeyVersion,
+ &i.ConfigVersion,
+ &i.Config,
+ &i.LastConnectedAt,
+ &i.LastErrorMessage,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const upsertConversation = `-- name: UpsertConversation :one
+INSERT INTO conversations (id, workspace_id, channel_id, external_id, type, title)
+VALUES ($1,$2,$3,$4,$5,$6)
+ON CONFLICT (channel_id, external_id) DO UPDATE
+SET type=excluded.type, title=excluded.title
+RETURNING id
+`
+
+type UpsertConversationParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ExternalID string `json:"external_id"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+}
+
+func (q *Queries) UpsertConversation(ctx context.Context, arg UpsertConversationParams) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, upsertConversation,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ChannelID,
+ arg.ExternalID,
+ arg.Type,
+ arg.Title,
+ )
+ var id uuid.UUID
+ err := row.Scan(&id)
+ return id, err
+}
diff --git a/internal/repository/postgres/sqlc/db.go b/internal/data/sqlc/db.go
similarity index 97%
rename from internal/repository/postgres/sqlc/db.go
rename to internal/data/sqlc/db.go
index 0e4c3f4..2725108 100644
--- a/internal/repository/postgres/sqlc/db.go
+++ b/internal/data/sqlc/db.go
@@ -2,7 +2,7 @@
// versions:
// sqlc v1.29.0
-package db
+package sqlc
import (
"context"
diff --git a/internal/data/sqlc/identity.sql.go b/internal/data/sqlc/identity.sql.go
new file mode 100644
index 0000000..6116261
--- /dev/null
+++ b/internal/data/sqlc/identity.sql.go
@@ -0,0 +1,581 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: identity.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const acceptWorkspaceInvitation = `-- name: AcceptWorkspaceInvitation :execrows
+UPDATE workspace_invitations SET accepted_at = now()
+WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL
+`
+
+func (q *Queries) AcceptWorkspaceInvitation(ctx context.Context, id uuid.UUID) (int64, error) {
+ result, err := q.db.Exec(ctx, acceptWorkspaceInvitation, id)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const activateUser = `-- name: ActivateUser :one
+UPDATE users
+SET status = 'active', accepted_terms_at = now(), accepted_privacy_at = now(), updated_at = now()
+WHERE id = $1 AND status = 'pending'
+RETURNING id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at
+`
+
+func (q *Queries) ActivateUser(ctx context.Context, id uuid.UUID) (User, error) {
+ row := q.db.QueryRow(ctx, activateUser, id)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.DisplayName,
+ &i.Username,
+ &i.Email,
+ &i.Status,
+ &i.AcceptedTermsAt,
+ &i.AcceptedPrivacyAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const addWorkspaceMember = `-- name: AddWorkspaceMember :exec
+INSERT INTO workspace_members (workspace_id, user_id, role)
+VALUES ($1, $2, $3)
+ON CONFLICT (workspace_id, user_id) DO NOTHING
+`
+
+type AddWorkspaceMemberParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ UserID uuid.UUID `json:"user_id"`
+ Role string `json:"role"`
+}
+
+func (q *Queries) AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) error {
+ _, err := q.db.Exec(ctx, addWorkspaceMember, arg.WorkspaceID, arg.UserID, arg.Role)
+ return err
+}
+
+const createOAuthIdentity = `-- name: CreateOAuthIdentity :exec
+INSERT INTO oauth_identities (issuer, subject, user_id)
+VALUES ($1, $2, $3)
+`
+
+type CreateOAuthIdentityParams struct {
+ Issuer string `json:"issuer"`
+ Subject string `json:"subject"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) CreateOAuthIdentity(ctx context.Context, arg CreateOAuthIdentityParams) error {
+ _, err := q.db.Exec(ctx, createOAuthIdentity, arg.Issuer, arg.Subject, arg.UserID)
+ return err
+}
+
+const createRetentionCleanupSchedule = `-- name: CreateRetentionCleanupSchedule :exec
+INSERT INTO retention_cleanup_schedules (workspace_id)
+VALUES ($1)
+`
+
+func (q *Queries) CreateRetentionCleanupSchedule(ctx context.Context, workspaceID uuid.UUID) error {
+ _, err := q.db.Exec(ctx, createRetentionCleanupSchedule, workspaceID)
+ return err
+}
+
+const createUser = `-- name: CreateUser :one
+INSERT INTO users (id, display_name, username, email, status)
+VALUES ($1, $2, $3, $4, $5)
+RETURNING id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at
+`
+
+type CreateUserParams struct {
+ ID uuid.UUID `json:"id"`
+ DisplayName string `json:"display_name"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Status string `json:"status"`
+}
+
+func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
+ row := q.db.QueryRow(ctx, createUser,
+ arg.ID,
+ arg.DisplayName,
+ arg.Username,
+ arg.Email,
+ arg.Status,
+ )
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.DisplayName,
+ &i.Username,
+ &i.Email,
+ &i.Status,
+ &i.AcceptedTermsAt,
+ &i.AcceptedPrivacyAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const createWorkspace = `-- name: CreateWorkspace :one
+INSERT INTO workspaces (id, name, slug, created_by)
+VALUES ($1, $2, $3, $4)
+RETURNING id, name, slug, report_retention_days, created_by, created_at, updated_at
+`
+
+type CreateWorkspaceParams struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ CreatedBy uuid.UUID `json:"created_by"`
+}
+
+func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
+ row := q.db.QueryRow(ctx, createWorkspace,
+ arg.ID,
+ arg.Name,
+ arg.Slug,
+ arg.CreatedBy,
+ )
+ var i Workspace
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Slug,
+ &i.ReportRetentionDays,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const createWorkspaceInvitation = `-- name: CreateWorkspaceInvitation :one
+INSERT INTO workspace_invitations (id, workspace_id, email, token_hash, created_by, expires_at)
+VALUES ($1, $2, $3, $4, $5, $6)
+RETURNING id, workspace_id, email, token_hash, created_by, expires_at, accepted_at, revoked_at, created_at
+`
+
+type CreateWorkspaceInvitationParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Email string `json:"email"`
+ TokenHash []byte `json:"token_hash"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+}
+
+func (q *Queries) CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error) {
+ row := q.db.QueryRow(ctx, createWorkspaceInvitation,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.Email,
+ arg.TokenHash,
+ arg.CreatedBy,
+ arg.ExpiresAt,
+ )
+ var i WorkspaceInvitation
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Email,
+ &i.TokenHash,
+ &i.CreatedBy,
+ &i.ExpiresAt,
+ &i.AcceptedAt,
+ &i.RevokedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getInvitationByTokenHash = `-- name: GetInvitationByTokenHash :one
+SELECT id, workspace_id, email, token_hash, created_by, expires_at, accepted_at, revoked_at, created_at FROM workspace_invitations
+WHERE token_hash = $1 AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now()
+`
+
+func (q *Queries) GetInvitationByTokenHash(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error) {
+ row := q.db.QueryRow(ctx, getInvitationByTokenHash, tokenHash)
+ var i WorkspaceInvitation
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Email,
+ &i.TokenHash,
+ &i.CreatedBy,
+ &i.ExpiresAt,
+ &i.AcceptedAt,
+ &i.RevokedAt,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getUser = `-- name: GetUser :one
+SELECT id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at FROM users WHERE id = $1
+`
+
+func (q *Queries) GetUser(ctx context.Context, id uuid.UUID) (User, error) {
+ row := q.db.QueryRow(ctx, getUser, id)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.DisplayName,
+ &i.Username,
+ &i.Email,
+ &i.Status,
+ &i.AcceptedTermsAt,
+ &i.AcceptedPrivacyAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getUserByIdentity = `-- name: GetUserByIdentity :one
+SELECT u.id, u.display_name, u.username, u.email, u.status, u.accepted_terms_at, u.accepted_privacy_at, u.created_at, u.updated_at FROM users u
+JOIN oauth_identities oi ON oi.user_id = u.id
+WHERE oi.issuer = $1 AND oi.subject = $2
+`
+
+type GetUserByIdentityParams struct {
+ Issuer string `json:"issuer"`
+ Subject string `json:"subject"`
+}
+
+func (q *Queries) GetUserByIdentity(ctx context.Context, arg GetUserByIdentityParams) (User, error) {
+ row := q.db.QueryRow(ctx, getUserByIdentity, arg.Issuer, arg.Subject)
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.DisplayName,
+ &i.Username,
+ &i.Email,
+ &i.Status,
+ &i.AcceptedTermsAt,
+ &i.AcceptedPrivacyAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getWorkspaceMembership = `-- name: GetWorkspaceMembership :one
+SELECT w.id, w.name, w.slug, w.report_retention_days, w.created_by, w.created_at, w.updated_at, wm.role FROM workspaces w
+JOIN workspace_members wm ON wm.workspace_id = w.id
+JOIN users u ON u.id = wm.user_id AND u.status = 'active'
+WHERE w.id = $1 AND wm.user_id = $2
+`
+
+type GetWorkspaceMembershipParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+type GetWorkspaceMembershipRow struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ ReportRetentionDays int32 `json:"report_retention_days"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ Role string `json:"role"`
+}
+
+func (q *Queries) GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error) {
+ row := q.db.QueryRow(ctx, getWorkspaceMembership, arg.ID, arg.UserID)
+ var i GetWorkspaceMembershipRow
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Slug,
+ &i.ReportRetentionDays,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Role,
+ )
+ return i, err
+}
+
+const listUserWorkspaces = `-- name: ListUserWorkspaces :many
+SELECT w.id, w.name, w.slug, w.report_retention_days, w.created_by, w.created_at, w.updated_at, wm.role FROM workspaces w
+JOIN workspace_members wm ON wm.workspace_id = w.id
+WHERE wm.user_id = $1
+ORDER BY w.created_at
+`
+
+type ListUserWorkspacesRow struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ ReportRetentionDays int32 `json:"report_retention_days"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ Role string `json:"role"`
+}
+
+func (q *Queries) ListUserWorkspaces(ctx context.Context, userID uuid.UUID) ([]ListUserWorkspacesRow, error) {
+ rows, err := q.db.Query(ctx, listUserWorkspaces, userID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListUserWorkspacesRow{}
+ for rows.Next() {
+ var i ListUserWorkspacesRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Slug,
+ &i.ReportRetentionDays,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.Role,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listWorkspaceInvitations = `-- name: ListWorkspaceInvitations :many
+SELECT id, workspace_id, email, token_hash, created_by, expires_at, accepted_at, revoked_at, created_at FROM workspace_invitations
+WHERE workspace_id = $1
+ AND (
+ $2::timestamptz IS NULL
+ OR (created_at, id) < ($2, $3::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT $4
+`
+
+type ListWorkspaceInvitationsParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ListWorkspaceInvitations(ctx context.Context, arg ListWorkspaceInvitationsParams) ([]WorkspaceInvitation, error) {
+ rows, err := q.db.Query(ctx, listWorkspaceInvitations,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []WorkspaceInvitation{}
+ for rows.Next() {
+ var i WorkspaceInvitation
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.Email,
+ &i.TokenHash,
+ &i.CreatedBy,
+ &i.ExpiresAt,
+ &i.AcceptedAt,
+ &i.RevokedAt,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listWorkspaceMembers = `-- name: ListWorkspaceMembers :many
+SELECT u.id, u.username, u.email, u.display_name, wm.role, wm.created_at
+FROM workspace_members wm JOIN users u ON u.id = wm.user_id
+WHERE wm.workspace_id = $1
+ AND (
+ $2::timestamptz IS NULL
+ OR (wm.created_at, wm.user_id) < ($2, $3::uuid)
+ )
+ORDER BY wm.created_at DESC, wm.user_id DESC
+LIMIT $4
+`
+
+type ListWorkspaceMembersParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+type ListWorkspaceMembersRow struct {
+ ID uuid.UUID `json:"id"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ DisplayName string `json:"display_name"`
+ Role string `json:"role"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+func (q *Queries) ListWorkspaceMembers(ctx context.Context, arg ListWorkspaceMembersParams) ([]ListWorkspaceMembersRow, error) {
+ rows, err := q.db.Query(ctx, listWorkspaceMembers,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListWorkspaceMembersRow{}
+ for rows.Next() {
+ var i ListWorkspaceMembersRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.Username,
+ &i.Email,
+ &i.DisplayName,
+ &i.Role,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const lockWorkspaceQuota = `-- name: LockWorkspaceQuota :one
+SELECT id FROM workspaces WHERE id=$1 FOR UPDATE
+`
+
+func (q *Queries) LockWorkspaceQuota(ctx context.Context, id uuid.UUID) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, lockWorkspaceQuota, id)
+ err := row.Scan(&id)
+ return id, err
+}
+
+const removeWorkspaceMember = `-- name: RemoveWorkspaceMember :execrows
+DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2 AND role <> 'owner'
+`
+
+type RemoveWorkspaceMemberParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) RemoveWorkspaceMember(ctx context.Context, arg RemoveWorkspaceMemberParams) (int64, error) {
+ result, err := q.db.Exec(ctx, removeWorkspaceMember, arg.WorkspaceID, arg.UserID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const revokeWorkspaceInvitation = `-- name: RevokeWorkspaceInvitation :execrows
+UPDATE workspace_invitations SET revoked_at = now()
+WHERE id = $1 AND workspace_id = $2 AND accepted_at IS NULL AND revoked_at IS NULL
+`
+
+type RevokeWorkspaceInvitationParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) RevokeWorkspaceInvitation(ctx context.Context, arg RevokeWorkspaceInvitationParams) (int64, error) {
+ result, err := q.db.Exec(ctx, revokeWorkspaceInvitation, arg.ID, arg.WorkspaceID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const updateUserProfile = `-- name: UpdateUserProfile :one
+UPDATE users SET display_name = $2, username = $3, email = $4, updated_at = now()
+WHERE id = $1 RETURNING id, display_name, username, email, status, accepted_terms_at, accepted_privacy_at, created_at, updated_at
+`
+
+type UpdateUserProfileParams struct {
+ ID uuid.UUID `json:"id"`
+ DisplayName string `json:"display_name"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+}
+
+func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) {
+ row := q.db.QueryRow(ctx, updateUserProfile,
+ arg.ID,
+ arg.DisplayName,
+ arg.Username,
+ arg.Email,
+ )
+ var i User
+ err := row.Scan(
+ &i.ID,
+ &i.DisplayName,
+ &i.Username,
+ &i.Email,
+ &i.Status,
+ &i.AcceptedTermsAt,
+ &i.AcceptedPrivacyAt,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const updateWorkspace = `-- name: UpdateWorkspace :one
+UPDATE workspaces
+SET name = COALESCE($1::text, name),
+ report_retention_days = COALESCE($2::integer, report_retention_days),
+ updated_at = clock_timestamp()
+WHERE id = $3
+RETURNING id, name, slug, report_retention_days, created_by, created_at, updated_at
+`
+
+type UpdateWorkspaceParams struct {
+ Name pgtype.Text `json:"name"`
+ ReportRetentionDays pgtype.Int4 `json:"report_retention_days"`
+ ID uuid.UUID `json:"id"`
+}
+
+func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
+ row := q.db.QueryRow(ctx, updateWorkspace, arg.Name, arg.ReportRetentionDays, arg.ID)
+ var i Workspace
+ err := row.Scan(
+ &i.ID,
+ &i.Name,
+ &i.Slug,
+ &i.ReportRetentionDays,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/data/sqlc/models.go b/internal/data/sqlc/models.go
new file mode 100644
index 0000000..6d15bcd
--- /dev/null
+++ b/internal/data/sqlc/models.go
@@ -0,0 +1,368 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package sqlc
+
+import (
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+type AnalysisProfile struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+ CurrentVersion int32 `json:"current_version"`
+ ArchivedAt pgtype.Timestamptz `json:"archived_at"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type AnalysisProfileVersion struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ Version int32 `json:"version"`
+ DimensionKey string `json:"dimension_key"`
+ Definition []byte `json:"definition"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type AnalysisReport struct {
+ ID uuid.UUID `json:"id"`
+ AnalysisRunID uuid.UUID `json:"analysis_run_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ SnapshotID uuid.UUID `json:"snapshot_id"`
+ CommitSha string `json:"commit_sha"`
+ SourceRef string `json:"source_ref"`
+ CommitAuthorName pgtype.Text `json:"commit_author_name"`
+ CommitAuthoredAt pgtype.Timestamptz `json:"commit_authored_at"`
+ CommitTitle pgtype.Text `json:"commit_title"`
+ DimensionKey string `json:"dimension_key"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ ProfileVersion string `json:"profile_version"`
+ ProfileSnapshot []byte `json:"profile_snapshot"`
+ AnalyzerVersion string `json:"analyzer_version"`
+ ExecutionEnvironment string `json:"execution_environment"`
+ StartedAt pgtype.Timestamptz `json:"started_at"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+ DurationMs int64 `json:"duration_ms"`
+ Result []byte `json:"result"`
+ RawArtifact []byte `json:"raw_artifact"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type AnalysisRun struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ SnapshotID uuid.UUID `json:"snapshot_id"`
+ CommitSha string `json:"commit_sha"`
+ RequestedBy uuid.UUID `json:"requested_by"`
+ DimensionKey string `json:"dimension_key"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ ProfileVersion string `json:"profile_version"`
+ ProfileSnapshot []byte `json:"profile_snapshot"`
+ AnalyzerVersion string `json:"analyzer_version"`
+ IdempotencyKey string `json:"idempotency_key"`
+ Attempt int32 `json:"attempt"`
+ RerunOf uuid.NullUUID `json:"rerun_of"`
+ Status string `json:"status"`
+ Stage string `json:"stage"`
+ ReportID uuid.NullUUID `json:"report_id"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+ FailedStage pgtype.Text `json:"failed_stage"`
+ ErrorCode pgtype.Text `json:"error_code"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+ Retryable bool `json:"retryable"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ StartedAt pgtype.Timestamptz `json:"started_at"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+}
+
+type AuditEvent struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.NullUUID `json:"workspace_id"`
+ ActorUserID uuid.NullUUID `json:"actor_user_id"`
+ Action string `json:"action"`
+ ResourceType string `json:"resource_type"`
+ ResourceID uuid.NullUUID `json:"resource_id"`
+ Metadata []byte `json:"metadata"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type Channel struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Enabled bool `json:"enabled"`
+ RuntimeStatus string `json:"runtime_status"`
+ SecretCiphertext []byte `json:"secret_ciphertext"`
+ SecretNonce []byte `json:"secret_nonce"`
+ KeyVersion pgtype.Int4 `json:"key_version"`
+ ConfigVersion int64 `json:"config_version"`
+ Config []byte `json:"config"`
+ LastConnectedAt pgtype.Timestamptz `json:"last_connected_at"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type ChannelExternalIdentity struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ UserID uuid.UUID `json:"user_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type ChannelIdentityLink struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ChannelVersion int64 `json:"channel_version"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ TokenHash []byte `json:"token_hash"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+ ConsumedAt pgtype.Timestamptz `json:"consumed_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type ChannelSubscription struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ EventType string `json:"event_type"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type CommitSnapshot struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ CommitSha string `json:"commit_sha"`
+ SourceRef string `json:"source_ref"`
+ GitRef string `json:"git_ref"`
+ AuthorName pgtype.Text `json:"author_name"`
+ AuthoredAt pgtype.Timestamptz `json:"authored_at"`
+ Title pgtype.Text `json:"title"`
+ SourceState string `json:"source_state"`
+ PurgeCleanupID uuid.NullUUID `json:"purge_cleanup_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type Conversation struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ExternalID string `json:"external_id"`
+ Type string `json:"type"`
+ Title string `json:"title"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type ConversationBinding struct {
+ ConversationID uuid.UUID `json:"conversation_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ BoundBy uuid.UUID `json:"bound_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type Message struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ ConversationID uuid.UUID `json:"conversation_id"`
+ ExternalID string `json:"external_id"`
+ SenderCanonicalID string `json:"sender_canonical_id"`
+ SenderDisplayName string `json:"sender_display_name"`
+ Content []byte `json:"content"`
+ OccurredAt pgtype.Timestamptz `json:"occurred_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type Notification struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ AnalysisRunID uuid.UUID `json:"analysis_run_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ EventType string `json:"event_type"`
+ Status string `json:"status"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ StartedAt pgtype.Timestamptz `json:"started_at"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+}
+
+type OauthIdentity struct {
+ Issuer string `json:"issuer"`
+ Subject string `json:"subject"`
+ UserID uuid.UUID `json:"user_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type ProviderConnection struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+ ProviderType string `json:"provider_type"`
+ BaseUrl string `json:"base_url"`
+ TokenCiphertext []byte `json:"token_ciphertext"`
+ TokenNonce []byte `json:"token_nonce"`
+ KeyVersion int32 `json:"key_version"`
+ CredentialVersion int64 `json:"credential_version"`
+ ProviderAccountID pgtype.Text `json:"provider_account_id"`
+ Login pgtype.Text `json:"login"`
+ DisplayName pgtype.Text `json:"display_name"`
+ Scopes []string `json:"scopes"`
+ IsDefault bool `json:"is_default"`
+ Status string `json:"status"`
+ LastValidatedAt pgtype.Timestamptz `json:"last_validated_at"`
+ LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type Repository struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ProviderType string `json:"provider_type"`
+ Name string `json:"name"`
+ RemoteUrl string `json:"remote_url"`
+ NormalizedUrl string `json:"normalized_url"`
+ ConfiguredRef string `json:"configured_ref"`
+ ConfigVersion int64 `json:"config_version"`
+ GitPath string `json:"git_path"`
+ Status string `json:"status"`
+ CurrentSnapshotID uuid.NullUUID `json:"current_snapshot_id"`
+ MirrorSizeBytes int64 `json:"mirror_size_bytes"`
+ LastSyncAt pgtype.Timestamptz `json:"last_sync_at"`
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+ ArchivedAt pgtype.Timestamptz `json:"archived_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type RepositoryOperation struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ ActorUserID uuid.UUID `json:"actor_user_id"`
+ ProviderConnectionID uuid.NullUUID `json:"provider_connection_id"`
+ CredentialVersion pgtype.Int8 `json:"credential_version"`
+ RepositoryVersion int64 `json:"repository_version"`
+ Kind string `json:"kind"`
+ RequestedProviderType string `json:"requested_provider_type"`
+ RequestedRemoteUrl string `json:"requested_remote_url"`
+ RequestedNormalizedUrl string `json:"requested_normalized_url"`
+ RequestedRef string `json:"requested_ref"`
+ PreviousProviderType string `json:"previous_provider_type"`
+ PreviousRemoteUrl string `json:"previous_remote_url"`
+ PreviousNormalizedUrl string `json:"previous_normalized_url"`
+ PreviousRef string `json:"previous_ref"`
+ Status string `json:"status"`
+ Outcome pgtype.Text `json:"outcome"`
+ ResolvedCommitSha pgtype.Text `json:"resolved_commit_sha"`
+ SnapshotID uuid.NullUUID `json:"snapshot_id"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ StartedAt pgtype.Timestamptz `json:"started_at"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+}
+
+type RepositorySourceKey struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NormalizedUrl string `json:"normalized_url"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type RetentionCleanup struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ScheduledFor pgtype.Timestamptz `json:"scheduled_for"`
+ RetentionDays int32 `json:"retention_days"`
+ Status string `json:"status"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+ DeletedRunCount int32 `json:"deleted_run_count"`
+ PurgedSnapshotCount int32 `json:"purged_snapshot_count"`
+ RequeuedRepositoryCount int32 `json:"requeued_repository_count"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ StartedAt pgtype.Timestamptz `json:"started_at"`
+ FinishedAt pgtype.Timestamptz `json:"finished_at"`
+}
+
+type RetentionCleanupSchedule struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NextRunAt pgtype.Timestamptz `json:"next_run_at"`
+ LastRunAt pgtype.Timestamptz `json:"last_run_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type User struct {
+ ID uuid.UUID `json:"id"`
+ DisplayName string `json:"display_name"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Status string `json:"status"`
+ AcceptedTermsAt pgtype.Timestamptz `json:"accepted_terms_at"`
+ AcceptedPrivacyAt pgtype.Timestamptz `json:"accepted_privacy_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type WorkflowDispatch struct {
+ ID uuid.UUID `json:"id"`
+ AggregateType string `json:"aggregate_type"`
+ AggregateID uuid.UUID `json:"aggregate_id"`
+ WorkflowName string `json:"workflow_name"`
+ Payload []byte `json:"payload"`
+ Status string `json:"status"`
+ Attempts int32 `json:"attempts"`
+ AvailableAt pgtype.Timestamptz `json:"available_at"`
+ LastError pgtype.Text `json:"last_error"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ DispatchedAt pgtype.Timestamptz `json:"dispatched_at"`
+}
+
+type Workspace struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ ReportRetentionDays int32 `json:"report_retention_days"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+type WorkspaceInvitation struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Email string `json:"email"`
+ TokenHash []byte `json:"token_hash"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ ExpiresAt pgtype.Timestamptz `json:"expires_at"`
+ AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
+ RevokedAt pgtype.Timestamptz `json:"revoked_at"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
+
+type WorkspaceMember struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ UserID uuid.UUID `json:"user_id"`
+ Role string `json:"role"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+}
diff --git a/internal/data/sqlc/notifications.sql.go b/internal/data/sqlc/notifications.sql.go
new file mode 100644
index 0000000..aab1555
--- /dev/null
+++ b/internal/data/sqlc/notifications.sql.go
@@ -0,0 +1,160 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: notifications.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const cancelChannelNotifications = `-- name: CancelChannelNotifications :exec
+WITH cancelled_notifications AS (
+ UPDATE notifications
+ SET status='cancelled', finished_at=now()
+ WHERE channel_id=$1 AND workspace_id=$2 AND status IN ('queued','running')
+ RETURNING id
+)
+UPDATE workflow_dispatches AS dispatch
+SET status='cancelled'
+FROM cancelled_notifications AS notification
+WHERE dispatch.aggregate_type='notification'
+ AND dispatch.aggregate_id=notification.id
+ AND dispatch.status='pending'
+`
+
+type CancelChannelNotificationsParams struct {
+ ChannelID uuid.UUID `json:"channel_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) CancelChannelNotifications(ctx context.Context, arg CancelChannelNotificationsParams) error {
+ _, err := q.db.Exec(ctx, cancelChannelNotifications, arg.ChannelID, arg.WorkspaceID)
+ return err
+}
+
+const cancelNotification = `-- name: CancelNotification :exec
+UPDATE notifications SET status='cancelled', finished_at=now()
+WHERE id=$1 AND status IN ('queued','running')
+`
+
+func (q *Queries) CancelNotification(ctx context.Context, id uuid.UUID) error {
+ _, err := q.db.Exec(ctx, cancelNotification, id)
+ return err
+}
+
+const createNotification = `-- name: CreateNotification :one
+INSERT INTO notifications (id, workspace_id, analysis_run_id, channel_id, event_type, status)
+VALUES ($1,$2,$3,$4,$5,'queued') RETURNING id, workspace_id, analysis_run_id, channel_id, event_type, status, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+type CreateNotificationParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ AnalysisRunID uuid.UUID `json:"analysis_run_id"`
+ ChannelID uuid.UUID `json:"channel_id"`
+ EventType string `json:"event_type"`
+}
+
+func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error) {
+ row := q.db.QueryRow(ctx, createNotification,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.AnalysisRunID,
+ arg.ChannelID,
+ arg.EventType,
+ )
+ var i Notification
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.AnalysisRunID,
+ &i.ChannelID,
+ &i.EventType,
+ &i.Status,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const failNotification = `-- name: FailNotification :exec
+WITH failed AS (
+ UPDATE notifications SET status='failed', error_message=$2, finished_at=now()
+ WHERE notifications.id=$1 AND notifications.status IN ('queued','running') RETURNING channel_id
+)
+UPDATE channels SET last_error_message=$2, updated_at=now()
+FROM failed WHERE channels.id=failed.channel_id
+`
+
+type FailNotificationParams struct {
+ ID uuid.UUID `json:"id"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+}
+
+func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) error {
+ _, err := q.db.Exec(ctx, failNotification, arg.ID, arg.LastErrorMessage)
+ return err
+}
+
+const finishNotification = `-- name: FinishNotification :execrows
+WITH delivered AS (
+ UPDATE notifications SET status='delivered', finished_at=now()
+ WHERE notifications.id=$1 AND notifications.status='running' RETURNING channel_id
+)
+UPDATE channels SET last_connected_at=now(), last_error_message=NULL, updated_at=now()
+FROM delivered WHERE channels.id=delivered.channel_id
+`
+
+func (q *Queries) FinishNotification(ctx context.Context, id uuid.UUID) (int64, error) {
+ result, err := q.db.Exec(ctx, finishNotification, id)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const setNotificationWorkflowID = `-- name: SetNotificationWorkflowID :exec
+UPDATE notifications SET workflow_run_id=$2 WHERE id=$1
+`
+
+type SetNotificationWorkflowIDParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+}
+
+func (q *Queries) SetNotificationWorkflowID(ctx context.Context, arg SetNotificationWorkflowIDParams) error {
+ _, err := q.db.Exec(ctx, setNotificationWorkflowID, arg.ID, arg.WorkflowRunID)
+ return err
+}
+
+const startNotification = `-- name: StartNotification :one
+UPDATE notifications SET status='running', started_at=now()
+WHERE id=$1 AND status='queued' RETURNING id, workspace_id, analysis_run_id, channel_id, event_type, status, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+func (q *Queries) StartNotification(ctx context.Context, id uuid.UUID) (Notification, error) {
+ row := q.db.QueryRow(ctx, startNotification, id)
+ var i Notification
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.AnalysisRunID,
+ &i.ChannelID,
+ &i.EventType,
+ &i.Status,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
diff --git a/internal/data/sqlc/providers.sql.go b/internal/data/sqlc/providers.sql.go
new file mode 100644
index 0000000..9a407f1
--- /dev/null
+++ b/internal/data/sqlc/providers.sql.go
@@ -0,0 +1,388 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: providers.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const clearDefaultProviderConnections = `-- name: ClearDefaultProviderConnections :exec
+UPDATE provider_connections SET is_default=false, updated_at=now()
+WHERE user_id=$1 AND provider_type=$2 AND status <> 'revoked'
+`
+
+type ClearDefaultProviderConnectionsParams struct {
+ UserID uuid.UUID `json:"user_id"`
+ ProviderType string `json:"provider_type"`
+}
+
+func (q *Queries) ClearDefaultProviderConnections(ctx context.Context, arg ClearDefaultProviderConnectionsParams) error {
+ _, err := q.db.Exec(ctx, clearDefaultProviderConnections, arg.UserID, arg.ProviderType)
+ return err
+}
+
+const createProviderConnection = `-- name: CreateProviderConnection :one
+INSERT INTO provider_connections (
+ id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version,
+ provider_account_id, login, display_name, scopes, is_default, status, last_validated_at
+) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',now())
+RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at
+`
+
+type CreateProviderConnectionParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+ ProviderType string `json:"provider_type"`
+ BaseUrl string `json:"base_url"`
+ TokenCiphertext []byte `json:"token_ciphertext"`
+ TokenNonce []byte `json:"token_nonce"`
+ KeyVersion int32 `json:"key_version"`
+ ProviderAccountID pgtype.Text `json:"provider_account_id"`
+ Login pgtype.Text `json:"login"`
+ DisplayName pgtype.Text `json:"display_name"`
+ Scopes []string `json:"scopes"`
+ IsDefault bool `json:"is_default"`
+}
+
+func (q *Queries) CreateProviderConnection(ctx context.Context, arg CreateProviderConnectionParams) (ProviderConnection, error) {
+ row := q.db.QueryRow(ctx, createProviderConnection,
+ arg.ID,
+ arg.UserID,
+ arg.ProviderType,
+ arg.BaseUrl,
+ arg.TokenCiphertext,
+ arg.TokenNonce,
+ arg.KeyVersion,
+ arg.ProviderAccountID,
+ arg.Login,
+ arg.DisplayName,
+ arg.Scopes,
+ arg.IsDefault,
+ )
+ var i ProviderConnection
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.ProviderType,
+ &i.BaseUrl,
+ &i.TokenCiphertext,
+ &i.TokenNonce,
+ &i.KeyVersion,
+ &i.CredentialVersion,
+ &i.ProviderAccountID,
+ &i.Login,
+ &i.DisplayName,
+ &i.Scopes,
+ &i.IsDefault,
+ &i.Status,
+ &i.LastValidatedAt,
+ &i.LastUsedAt,
+ &i.LastErrorCode,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getProviderConnection = `-- name: GetProviderConnection :one
+SELECT id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at FROM provider_connections WHERE id = $1 AND user_id = $2 AND status <> 'revoked'
+`
+
+type GetProviderConnectionParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) GetProviderConnection(ctx context.Context, arg GetProviderConnectionParams) (ProviderConnection, error) {
+ row := q.db.QueryRow(ctx, getProviderConnection, arg.ID, arg.UserID)
+ var i ProviderConnection
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.ProviderType,
+ &i.BaseUrl,
+ &i.TokenCiphertext,
+ &i.TokenNonce,
+ &i.KeyVersion,
+ &i.CredentialVersion,
+ &i.ProviderAccountID,
+ &i.Login,
+ &i.DisplayName,
+ &i.Scopes,
+ &i.IsDefault,
+ &i.Status,
+ &i.LastValidatedAt,
+ &i.LastUsedAt,
+ &i.LastErrorCode,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const listProviderConnections = `-- name: ListProviderConnections :many
+SELECT id, user_id, provider_type, base_url, provider_account_id, login, display_name,
+ scopes, is_default, status, credential_version, last_validated_at, last_used_at, last_error_code,
+ created_at, updated_at
+FROM provider_connections WHERE user_id = $1 AND status <> 'revoked'
+ AND (
+ $2::timestamptz IS NULL
+ OR (created_at, id) < ($2, $3::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT $4
+`
+
+type ListProviderConnectionsParams struct {
+ UserID uuid.UUID `json:"user_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+type ListProviderConnectionsRow struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+ ProviderType string `json:"provider_type"`
+ BaseUrl string `json:"base_url"`
+ ProviderAccountID pgtype.Text `json:"provider_account_id"`
+ Login pgtype.Text `json:"login"`
+ DisplayName pgtype.Text `json:"display_name"`
+ Scopes []string `json:"scopes"`
+ IsDefault bool `json:"is_default"`
+ Status string `json:"status"`
+ CredentialVersion int64 `json:"credential_version"`
+ LastValidatedAt pgtype.Timestamptz `json:"last_validated_at"`
+ LastUsedAt pgtype.Timestamptz `json:"last_used_at"`
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+}
+
+func (q *Queries) ListProviderConnections(ctx context.Context, arg ListProviderConnectionsParams) ([]ListProviderConnectionsRow, error) {
+ rows, err := q.db.Query(ctx, listProviderConnections,
+ arg.UserID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListProviderConnectionsRow{}
+ for rows.Next() {
+ var i ListProviderConnectionsRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.ProviderType,
+ &i.BaseUrl,
+ &i.ProviderAccountID,
+ &i.Login,
+ &i.DisplayName,
+ &i.Scopes,
+ &i.IsDefault,
+ &i.Status,
+ &i.CredentialVersion,
+ &i.LastValidatedAt,
+ &i.LastUsedAt,
+ &i.LastErrorCode,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const markProviderConnectionUsed = `-- name: MarkProviderConnectionUsed :execrows
+UPDATE provider_connections SET last_used_at=now(), updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status='active'
+`
+
+type MarkProviderConnectionUsedParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) MarkProviderConnectionUsed(ctx context.Context, arg MarkProviderConnectionUsedParams) (int64, error) {
+ result, err := q.db.Exec(ctx, markProviderConnectionUsed, arg.ID, arg.UserID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const replaceProviderCredential = `-- name: ReplaceProviderCredential :one
+UPDATE provider_connections
+SET token_ciphertext=$3, token_nonce=$4, key_version=$5,
+ credential_version=credential_version+1, status='active', last_error_code=NULL, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status <> 'revoked'
+RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at
+`
+
+type ReplaceProviderCredentialParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+ TokenCiphertext []byte `json:"token_ciphertext"`
+ TokenNonce []byte `json:"token_nonce"`
+ KeyVersion int32 `json:"key_version"`
+}
+
+func (q *Queries) ReplaceProviderCredential(ctx context.Context, arg ReplaceProviderCredentialParams) (ProviderConnection, error) {
+ row := q.db.QueryRow(ctx, replaceProviderCredential,
+ arg.ID,
+ arg.UserID,
+ arg.TokenCiphertext,
+ arg.TokenNonce,
+ arg.KeyVersion,
+ )
+ var i ProviderConnection
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.ProviderType,
+ &i.BaseUrl,
+ &i.TokenCiphertext,
+ &i.TokenNonce,
+ &i.KeyVersion,
+ &i.CredentialVersion,
+ &i.ProviderAccountID,
+ &i.Login,
+ &i.DisplayName,
+ &i.Scopes,
+ &i.IsDefault,
+ &i.Status,
+ &i.LastValidatedAt,
+ &i.LastUsedAt,
+ &i.LastErrorCode,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const revokeProviderConnection = `-- name: RevokeProviderConnection :execrows
+UPDATE provider_connections
+SET status='revoked', is_default=false, token_ciphertext=decode('', 'hex'),
+ token_nonce=decode('', 'hex'), credential_version=credential_version+1, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status <> 'revoked'
+`
+
+type RevokeProviderConnectionParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) RevokeProviderConnection(ctx context.Context, arg RevokeProviderConnectionParams) (int64, error) {
+ result, err := q.db.Exec(ctx, revokeProviderConnection, arg.ID, arg.UserID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const setDefaultProviderConnection = `-- name: SetDefaultProviderConnection :one
+UPDATE provider_connections SET is_default=true, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status='active'
+RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at
+`
+
+type SetDefaultProviderConnectionParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+}
+
+func (q *Queries) SetDefaultProviderConnection(ctx context.Context, arg SetDefaultProviderConnectionParams) (ProviderConnection, error) {
+ row := q.db.QueryRow(ctx, setDefaultProviderConnection, arg.ID, arg.UserID)
+ var i ProviderConnection
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.ProviderType,
+ &i.BaseUrl,
+ &i.TokenCiphertext,
+ &i.TokenNonce,
+ &i.KeyVersion,
+ &i.CredentialVersion,
+ &i.ProviderAccountID,
+ &i.Login,
+ &i.DisplayName,
+ &i.Scopes,
+ &i.IsDefault,
+ &i.Status,
+ &i.LastValidatedAt,
+ &i.LastUsedAt,
+ &i.LastErrorCode,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const setProviderConnectionValidation = `-- name: SetProviderConnectionValidation :one
+UPDATE provider_connections
+SET provider_account_id=$3, login=$4, display_name=$5, scopes=$6, status=$7,
+ last_validated_at=now(), last_error_code=$8, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status <> 'revoked'
+RETURNING id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version, credential_version, provider_account_id, login, display_name, scopes, is_default, status, last_validated_at, last_used_at, last_error_code, created_at, updated_at
+`
+
+type SetProviderConnectionValidationParams struct {
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+ ProviderAccountID pgtype.Text `json:"provider_account_id"`
+ Login pgtype.Text `json:"login"`
+ DisplayName pgtype.Text `json:"display_name"`
+ Scopes []string `json:"scopes"`
+ Status string `json:"status"`
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+}
+
+func (q *Queries) SetProviderConnectionValidation(ctx context.Context, arg SetProviderConnectionValidationParams) (ProviderConnection, error) {
+ row := q.db.QueryRow(ctx, setProviderConnectionValidation,
+ arg.ID,
+ arg.UserID,
+ arg.ProviderAccountID,
+ arg.Login,
+ arg.DisplayName,
+ arg.Scopes,
+ arg.Status,
+ arg.LastErrorCode,
+ )
+ var i ProviderConnection
+ err := row.Scan(
+ &i.ID,
+ &i.UserID,
+ &i.ProviderType,
+ &i.BaseUrl,
+ &i.TokenCiphertext,
+ &i.TokenNonce,
+ &i.KeyVersion,
+ &i.CredentialVersion,
+ &i.ProviderAccountID,
+ &i.Login,
+ &i.DisplayName,
+ &i.Scopes,
+ &i.IsDefault,
+ &i.Status,
+ &i.LastValidatedAt,
+ &i.LastUsedAt,
+ &i.LastErrorCode,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/data/sqlc/querier.go b/internal/data/sqlc/querier.go
new file mode 100644
index 0000000..0da32a5
--- /dev/null
+++ b/internal/data/sqlc/querier.go
@@ -0,0 +1,164 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+type Querier interface {
+ AcceptWorkspaceInvitation(ctx context.Context, id uuid.UUID) (int64, error)
+ ActivateUser(ctx context.Context, id uuid.UUID) (User, error)
+ AddRequeuedRepositoryCount(ctx context.Context, arg AddRequeuedRepositoryCountParams) (int64, error)
+ AddRetentionCleanupProgress(ctx context.Context, arg AddRetentionCleanupProgressParams) (int64, error)
+ AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) error
+ AdvanceAnalysisProfile(ctx context.Context, arg AdvanceAnalysisProfileParams) (AnalysisProfile, error)
+ AdvanceRetentionSchedule(ctx context.Context, arg AdvanceRetentionScheduleParams) (int64, error)
+ AnalysisClock(ctx context.Context) (pgtype.Timestamptz, error)
+ ArchiveAnalysisProfile(ctx context.Context, arg ArchiveAnalysisProfileParams) (int64, error)
+ ArchiveRepository(ctx context.Context, arg ArchiveRepositoryParams) (Repository, error)
+ CancelAnalysisRun(ctx context.Context, arg CancelAnalysisRunParams) (pgtype.Text, error)
+ CancelChannelNotifications(ctx context.Context, arg CancelChannelNotificationsParams) error
+ CancelNotification(ctx context.Context, id uuid.UUID) error
+ CancelRepositoryOperation(ctx context.Context, arg CancelRepositoryOperationParams) (RepositoryOperation, error)
+ CancelWorkflowDispatch(ctx context.Context, arg CancelWorkflowDispatchParams) error
+ CheckActiveChannelVersion(ctx context.Context, arg CheckActiveChannelVersionParams) (int64, error)
+ ClaimDueRetentionSchedules(ctx context.Context, limit int32) ([]ClaimDueRetentionSchedulesRow, error)
+ ClaimRepositoriesReadyForPurge(ctx context.Context, arg ClaimRepositoriesReadyForPurgeParams) ([]Repository, error)
+ ClaimWorkflowDispatches(ctx context.Context, arg ClaimWorkflowDispatchesParams) ([]WorkflowDispatch, error)
+ ClearDefaultProviderConnections(ctx context.Context, arg ClearDefaultProviderConnectionsParams) error
+ CompleteRepositoryLogicalDeletion(ctx context.Context, arg CompleteRepositoryLogicalDeletionParams) (int64, error)
+ CompleteSnapshotPurge(ctx context.Context, arg CompleteSnapshotPurgeParams) (int64, error)
+ ConsumeChannelIdentityLink(ctx context.Context, id uuid.UUID) (int64, error)
+ CountActiveAnalysisRuns(ctx context.Context, workspaceID uuid.UUID) (int64, error)
+ CountManagedRepositories(ctx context.Context, workspaceID uuid.UUID) (int64, error)
+ CreateAnalysisProfile(ctx context.Context, arg CreateAnalysisProfileParams) (AnalysisProfile, error)
+ CreateAnalysisProfileVersion(ctx context.Context, arg CreateAnalysisProfileVersionParams) (AnalysisProfileVersion, error)
+ CreateAnalysisReport(ctx context.Context, arg CreateAnalysisReportParams) (AnalysisReport, error)
+ CreateAnalysisRun(ctx context.Context, arg CreateAnalysisRunParams) (AnalysisRun, error)
+ CreateAuditEvent(ctx context.Context, arg CreateAuditEventParams) error
+ CreateChannel(ctx context.Context, arg CreateChannelParams) (Channel, error)
+ CreateChannelIdentityLink(ctx context.Context, arg CreateChannelIdentityLinkParams) (ChannelIdentityLink, error)
+ CreateChannelSubscription(ctx context.Context, arg CreateChannelSubscriptionParams) error
+ CreateNotification(ctx context.Context, arg CreateNotificationParams) (Notification, error)
+ CreateOAuthIdentity(ctx context.Context, arg CreateOAuthIdentityParams) error
+ CreateProviderConnection(ctx context.Context, arg CreateProviderConnectionParams) (ProviderConnection, error)
+ CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error)
+ CreateRepositoryOperation(ctx context.Context, arg CreateRepositoryOperationParams) (RepositoryOperation, error)
+ CreateRetentionCleanup(ctx context.Context, arg CreateRetentionCleanupParams) (RetentionCleanup, error)
+ CreateRetentionCleanupSchedule(ctx context.Context, workspaceID uuid.UUID) error
+ CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
+ CreateWorkflowDispatch(ctx context.Context, arg CreateWorkflowDispatchParams) (WorkflowDispatch, error)
+ CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error)
+ CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error)
+ DelayWorkflowDispatch(ctx context.Context, arg DelayWorkflowDispatchParams) error
+ DeleteChannel(ctx context.Context, arg DeleteChannelParams) (int64, error)
+ DeleteChannelSubscriptions(ctx context.Context, arg DeleteChannelSubscriptionsParams) error
+ DeleteConversationBinding(ctx context.Context, conversationID uuid.UUID) (int64, error)
+ DeleteExpiredAnalysisRuns(ctx context.Context, arg DeleteExpiredAnalysisRunsParams) ([]uuid.UUID, error)
+ DeleteRepository(ctx context.Context, arg DeleteRepositoryParams) (int64, error)
+ EnsureSnapshot(ctx context.Context, arg EnsureSnapshotParams) (CommitSnapshot, error)
+ FailAnalysisRun(ctx context.Context, arg FailAnalysisRunParams) (AnalysisRun, error)
+ FailNotification(ctx context.Context, arg FailNotificationParams) error
+ FailRepositoryOperation(ctx context.Context, arg FailRepositoryOperationParams) (RepositoryOperation, error)
+ FailRetentionCleanup(ctx context.Context, arg FailRetentionCleanupParams) (int64, error)
+ FinishAnalysisRun(ctx context.Context, arg FinishAnalysisRunParams) (AnalysisRun, error)
+ FinishNotification(ctx context.Context, id uuid.UUID) (int64, error)
+ FinishRepositoryOperation(ctx context.Context, arg FinishRepositoryOperationParams) (RepositoryOperation, error)
+ FinishRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error)
+ GetAnalysisProfile(ctx context.Context, arg GetAnalysisProfileParams) (GetAnalysisProfileRow, error)
+ GetAnalysisReport(ctx context.Context, arg GetAnalysisReportParams) (AnalysisReport, error)
+ GetAnalysisReportByRun(ctx context.Context, analysisRunID uuid.UUID) (AnalysisReport, error)
+ GetAnalysisRun(ctx context.Context, arg GetAnalysisRunParams) (AnalysisRun, error)
+ GetAnalysisRunByAttempt(ctx context.Context, arg GetAnalysisRunByAttemptParams) (AnalysisRun, error)
+ GetAnalysisRunWork(ctx context.Context, id uuid.UUID) (AnalysisRun, error)
+ GetChannel(ctx context.Context, arg GetChannelParams) (Channel, error)
+ GetChannelExternalIdentity(ctx context.Context, arg GetChannelExternalIdentityParams) (uuid.UUID, error)
+ GetChannelIdentityLink(ctx context.Context, tokenHash []byte) (ChannelIdentityLink, error)
+ GetConversationBinding(ctx context.Context, conversationID uuid.UUID) (uuid.UUID, error)
+ GetConversationIDByExternal(ctx context.Context, arg GetConversationIDByExternalParams) (uuid.UUID, error)
+ GetDefaultAnalysisProfile(ctx context.Context, workspaceID uuid.UUID) (GetDefaultAnalysisProfileRow, error)
+ GetInvitationByTokenHash(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error)
+ GetProviderConnection(ctx context.Context, arg GetProviderConnectionParams) (ProviderConnection, error)
+ GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error)
+ GetRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error)
+ GetRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error)
+ GetSnapshot(ctx context.Context, arg GetSnapshotParams) (CommitSnapshot, error)
+ GetUser(ctx context.Context, id uuid.UUID) (User, error)
+ GetUserByIdentity(ctx context.Context, arg GetUserByIdentityParams) (User, error)
+ GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error)
+ GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (GetWorkspaceOverviewRow, error)
+ GetWorkspaceSnapshot(ctx context.Context, arg GetWorkspaceSnapshotParams) (CommitSnapshot, error)
+ HasRepositoryAnalysisRuns(ctx context.Context, repositoryID uuid.UUID) (bool, error)
+ InsertInboundMessage(ctx context.Context, arg InsertInboundMessageParams) (int64, error)
+ IsWorkflowDispatchPending(ctx context.Context, id uuid.UUID) (bool, error)
+ ListAnalysisProfiles(ctx context.Context, arg ListAnalysisProfilesParams) ([]ListAnalysisProfilesRow, error)
+ ListAnalysisRuns(ctx context.Context, arg ListAnalysisRunsParams) ([]AnalysisRun, error)
+ ListChannelSubscriptions(ctx context.Context, workspaceID uuid.UUID) ([]ListChannelSubscriptionsRow, error)
+ ListChannels(ctx context.Context, arg ListChannelsParams) ([]Channel, error)
+ ListConversations(ctx context.Context, arg ListConversationsParams) ([]ListConversationsRow, error)
+ ListMessages(ctx context.Context, arg ListMessagesParams) ([]ListMessagesRow, error)
+ ListProviderConnections(ctx context.Context, arg ListProviderConnectionsParams) ([]ListProviderConnectionsRow, error)
+ ListRepositories(ctx context.Context, arg ListRepositoriesParams) ([]ListRepositoriesRow, error)
+ ListRepositoryOperations(ctx context.Context, arg ListRepositoryOperationsParams) ([]RepositoryOperation, error)
+ ListRepositorySnapshots(ctx context.Context, arg ListRepositorySnapshotsParams) ([]CommitSnapshot, error)
+ ListRuntimeChannels(ctx context.Context) ([]Channel, error)
+ ListSubscribedChannels(ctx context.Context, arg ListSubscribedChannelsParams) ([]Channel, error)
+ ListUserWorkspaces(ctx context.Context, userID uuid.UUID) ([]ListUserWorkspacesRow, error)
+ ListWorkspaceInvitations(ctx context.Context, arg ListWorkspaceInvitationsParams) ([]WorkspaceInvitation, error)
+ ListWorkspaceMembers(ctx context.Context, arg ListWorkspaceMembersParams) ([]ListWorkspaceMembersRow, error)
+ LockActiveAnalysisProfiles(ctx context.Context, workspaceID uuid.UUID) ([]uuid.UUID, error)
+ LockAnalysisSnapshot(ctx context.Context, arg LockAnalysisSnapshotParams) (string, error)
+ LockChannelRuntime(ctx context.Context, arg LockChannelRuntimeParams) (int64, error)
+ LockPurgeableSnapshotCandidates(ctx context.Context, arg LockPurgeableSnapshotCandidatesParams) ([]uuid.UUID, error)
+ LockWorkspaceQuota(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
+ MarkChannelRuntimeConnected(ctx context.Context, arg MarkChannelRuntimeConnectedParams) (int64, error)
+ MarkChannelRuntimeError(ctx context.Context, arg MarkChannelRuntimeErrorParams) (int64, error)
+ MarkProviderConnectionUsed(ctx context.Context, arg MarkProviderConnectionUsedParams) (int64, error)
+ MarkSnapshotsPurged(ctx context.Context, repositoryID uuid.UUID) error
+ MarkSnapshotsPurging(ctx context.Context, arg MarkSnapshotsPurgingParams) ([]MarkSnapshotsPurgingRow, error)
+ MarkWorkflowDispatched(ctx context.Context, id uuid.UUID) (int64, error)
+ ProjectRepositoryOperationFailure(ctx context.Context, arg ProjectRepositoryOperationFailureParams) (int64, error)
+ ReactivateRepositoryPurge(ctx context.Context, id uuid.UUID) (int64, error)
+ ReleaseRepositorySource(ctx context.Context, arg ReleaseRepositorySourceParams) (int64, error)
+ RemoveWorkspaceMember(ctx context.Context, arg RemoveWorkspaceMemberParams) (int64, error)
+ ReplaceProviderCredential(ctx context.Context, arg ReplaceProviderCredentialParams) (ProviderConnection, error)
+ RequestRepositoryDeletion(ctx context.Context, arg RequestRepositoryDeletionParams) (Repository, error)
+ ReserveRepositorySource(ctx context.Context, arg ReserveRepositorySourceParams) (uuid.UUID, error)
+ RestoreDeletedRepositoryAfterPurgeFailure(ctx context.Context, arg RestoreDeletedRepositoryAfterPurgeFailureParams) (int64, error)
+ RestoreRepository(ctx context.Context, arg RestoreRepositoryParams) (Repository, error)
+ RestoreRepositoryAfterOperation(ctx context.Context, arg RestoreRepositoryAfterOperationParams) (int64, error)
+ RevokeProviderConnection(ctx context.Context, arg RevokeProviderConnectionParams) (int64, error)
+ RevokeWorkspaceInvitation(ctx context.Context, arg RevokeWorkspaceInvitationParams) (int64, error)
+ RotateChannelCredential(ctx context.Context, arg RotateChannelCredentialParams) (Channel, error)
+ SetAnalysisRunStage(ctx context.Context, arg SetAnalysisRunStageParams) (int64, error)
+ SetAnalysisWorkflowID(ctx context.Context, arg SetAnalysisWorkflowIDParams) error
+ SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (Channel, error)
+ SetDefaultProviderConnection(ctx context.Context, arg SetDefaultProviderConnectionParams) (ProviderConnection, error)
+ SetNotificationWorkflowID(ctx context.Context, arg SetNotificationWorkflowIDParams) error
+ SetProviderConnectionValidation(ctx context.Context, arg SetProviderConnectionValidationParams) (ProviderConnection, error)
+ SetRepositoryCurrentSnapshot(ctx context.Context, arg SetRepositoryCurrentSnapshotParams) (Repository, error)
+ SetRepositoryOperationWorkflowID(ctx context.Context, arg SetRepositoryOperationWorkflowIDParams) error
+ SetRepositoryStatus(ctx context.Context, arg SetRepositoryStatusParams) error
+ SetRepositorySyncing(ctx context.Context, arg SetRepositorySyncingParams) (int64, error)
+ SetRetentionCleanupWorkflowID(ctx context.Context, arg SetRetentionCleanupWorkflowIDParams) error
+ StageRepositoryUpdate(ctx context.Context, arg StageRepositoryUpdateParams) (Repository, error)
+ StartAnalysisRun(ctx context.Context, id uuid.UUID) (AnalysisRun, error)
+ StartNotification(ctx context.Context, id uuid.UUID) (Notification, error)
+ StartRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error)
+ StartRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error)
+ UpdateChannel(ctx context.Context, arg UpdateChannelParams) (Channel, error)
+ UpdateRepository(ctx context.Context, arg UpdateRepositoryParams) (Repository, error)
+ UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error)
+ UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
+ UpsertChannelExternalIdentity(ctx context.Context, arg UpsertChannelExternalIdentityParams) error
+ UpsertConversation(ctx context.Context, arg UpsertConversationParams) (uuid.UUID, error)
+ UpsertConversationBinding(ctx context.Context, arg UpsertConversationBindingParams) error
+}
+
+var _ Querier = (*Queries)(nil)
diff --git a/internal/data/sqlc/repositories.sql.go b/internal/data/sqlc/repositories.sql.go
new file mode 100644
index 0000000..f55ff90
--- /dev/null
+++ b/internal/data/sqlc/repositories.sql.go
@@ -0,0 +1,1264 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: repositories.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const archiveRepository = `-- name: ArchiveRepository :one
+UPDATE repositories SET archived_at=now(), updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type ArchiveRepositoryParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) ArchiveRepository(ctx context.Context, arg ArchiveRepositoryParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, archiveRepository, arg.ID, arg.WorkspaceID)
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const cancelRepositoryOperation = `-- name: CancelRepositoryOperation :one
+UPDATE repository_operations SET status='cancelled', finished_at=now()
+WHERE id=$1 AND repository_id=$2 AND status IN ('queued','running')
+RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+type CancelRepositoryOperationParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+}
+
+func (q *Queries) CancelRepositoryOperation(ctx context.Context, arg CancelRepositoryOperationParams) (RepositoryOperation, error) {
+ row := q.db.QueryRow(ctx, cancelRepositoryOperation, arg.ID, arg.RepositoryID)
+ var i RepositoryOperation
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const completeRepositoryLogicalDeletion = `-- name: CompleteRepositoryLogicalDeletion :execrows
+WITH completed AS (
+ UPDATE repository_operations
+ SET status='succeeded', outcome='no_change', finished_at=now()
+ WHERE repository_operations.id=$1 AND repository_operations.repository_id=$2 AND repository_operations.status='running'
+ RETURNING repository_operations.repository_id
+)
+UPDATE repositories
+SET status='deleted', archived_at=COALESCE(archived_at, now()), deleted_at=now(), updated_at=now()
+FROM completed
+WHERE repositories.id=completed.repository_id
+`
+
+type CompleteRepositoryLogicalDeletionParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+}
+
+func (q *Queries) CompleteRepositoryLogicalDeletion(ctx context.Context, arg CompleteRepositoryLogicalDeletionParams) (int64, error) {
+ result, err := q.db.Exec(ctx, completeRepositoryLogicalDeletion, arg.ID, arg.RepositoryID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const countManagedRepositories = `-- name: CountManagedRepositories :one
+SELECT count(*) FROM repositories WHERE workspace_id=$1 AND status <> 'deleted'
+`
+
+func (q *Queries) CountManagedRepositories(ctx context.Context, workspaceID uuid.UUID) (int64, error) {
+ row := q.db.QueryRow(ctx, countManagedRepositories, workspaceID)
+ var count int64
+ err := row.Scan(&count)
+ return count, err
+}
+
+const createRepository = `-- name: CreateRepository :one
+INSERT INTO repositories (id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, git_path, status, created_by)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'provisioning',$9) RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type CreateRepositoryParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ProviderType string `json:"provider_type"`
+ Name string `json:"name"`
+ RemoteUrl string `json:"remote_url"`
+ NormalizedUrl string `json:"normalized_url"`
+ ConfiguredRef string `json:"configured_ref"`
+ GitPath string `json:"git_path"`
+ CreatedBy uuid.UUID `json:"created_by"`
+}
+
+func (q *Queries) CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, createRepository,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ProviderType,
+ arg.Name,
+ arg.RemoteUrl,
+ arg.NormalizedUrl,
+ arg.ConfiguredRef,
+ arg.GitPath,
+ arg.CreatedBy,
+ )
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const createRepositoryOperation = `-- name: CreateRepositoryOperation :one
+INSERT INTO repository_operations (
+ id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind,
+ requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref,
+ previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status
+) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'queued') RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+type CreateRepositoryOperationParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ ActorUserID uuid.UUID `json:"actor_user_id"`
+ ProviderConnectionID uuid.NullUUID `json:"provider_connection_id"`
+ CredentialVersion pgtype.Int8 `json:"credential_version"`
+ RepositoryVersion int64 `json:"repository_version"`
+ Kind string `json:"kind"`
+ RequestedProviderType string `json:"requested_provider_type"`
+ RequestedRemoteUrl string `json:"requested_remote_url"`
+ RequestedNormalizedUrl string `json:"requested_normalized_url"`
+ RequestedRef string `json:"requested_ref"`
+ PreviousProviderType string `json:"previous_provider_type"`
+ PreviousRemoteUrl string `json:"previous_remote_url"`
+ PreviousNormalizedUrl string `json:"previous_normalized_url"`
+ PreviousRef string `json:"previous_ref"`
+}
+
+func (q *Queries) CreateRepositoryOperation(ctx context.Context, arg CreateRepositoryOperationParams) (RepositoryOperation, error) {
+ row := q.db.QueryRow(ctx, createRepositoryOperation,
+ arg.ID,
+ arg.RepositoryID,
+ arg.ActorUserID,
+ arg.ProviderConnectionID,
+ arg.CredentialVersion,
+ arg.RepositoryVersion,
+ arg.Kind,
+ arg.RequestedProviderType,
+ arg.RequestedRemoteUrl,
+ arg.RequestedNormalizedUrl,
+ arg.RequestedRef,
+ arg.PreviousProviderType,
+ arg.PreviousRemoteUrl,
+ arg.PreviousNormalizedUrl,
+ arg.PreviousRef,
+ )
+ var i RepositoryOperation
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const deleteRepository = `-- name: DeleteRepository :execrows
+DELETE FROM repositories WHERE id=$1 AND workspace_id=$2
+`
+
+type DeleteRepositoryParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) DeleteRepository(ctx context.Context, arg DeleteRepositoryParams) (int64, error) {
+ result, err := q.db.Exec(ctx, deleteRepository, arg.ID, arg.WorkspaceID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const ensureSnapshot = `-- name: EnsureSnapshot :one
+INSERT INTO commit_snapshots (id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'available')
+ON CONFLICT (repository_id, commit_sha) DO UPDATE
+SET id=commit_snapshots.id
+RETURNING id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state, purge_cleanup_id, created_at
+`
+
+type EnsureSnapshotParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ CommitSha string `json:"commit_sha"`
+ SourceRef string `json:"source_ref"`
+ GitRef string `json:"git_ref"`
+ AuthorName pgtype.Text `json:"author_name"`
+ AuthoredAt pgtype.Timestamptz `json:"authored_at"`
+ Title pgtype.Text `json:"title"`
+}
+
+func (q *Queries) EnsureSnapshot(ctx context.Context, arg EnsureSnapshotParams) (CommitSnapshot, error) {
+ row := q.db.QueryRow(ctx, ensureSnapshot,
+ arg.ID,
+ arg.RepositoryID,
+ arg.CommitSha,
+ arg.SourceRef,
+ arg.GitRef,
+ arg.AuthorName,
+ arg.AuthoredAt,
+ arg.Title,
+ )
+ var i CommitSnapshot
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.GitRef,
+ &i.AuthorName,
+ &i.AuthoredAt,
+ &i.Title,
+ &i.SourceState,
+ &i.PurgeCleanupID,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const failRepositoryOperation = `-- name: FailRepositoryOperation :one
+UPDATE repository_operations SET status='failed', error_message=$2, finished_at=now()
+WHERE id=$1 AND status IN ('queued','running') RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+type FailRepositoryOperationParams struct {
+ ID uuid.UUID `json:"id"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+}
+
+func (q *Queries) FailRepositoryOperation(ctx context.Context, arg FailRepositoryOperationParams) (RepositoryOperation, error) {
+ row := q.db.QueryRow(ctx, failRepositoryOperation, arg.ID, arg.ErrorMessage)
+ var i RepositoryOperation
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const finishRepositoryOperation = `-- name: FinishRepositoryOperation :one
+UPDATE repository_operations
+SET status='succeeded', outcome=$2, resolved_commit_sha=$3, snapshot_id=$4, finished_at=now()
+WHERE id=$1 AND status='running' RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+type FinishRepositoryOperationParams struct {
+ ID uuid.UUID `json:"id"`
+ Outcome pgtype.Text `json:"outcome"`
+ ResolvedCommitSha pgtype.Text `json:"resolved_commit_sha"`
+ SnapshotID uuid.NullUUID `json:"snapshot_id"`
+}
+
+func (q *Queries) FinishRepositoryOperation(ctx context.Context, arg FinishRepositoryOperationParams) (RepositoryOperation, error) {
+ row := q.db.QueryRow(ctx, finishRepositoryOperation,
+ arg.ID,
+ arg.Outcome,
+ arg.ResolvedCommitSha,
+ arg.SnapshotID,
+ )
+ var i RepositoryOperation
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getRepository = `-- name: GetRepository :one
+SELECT id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at FROM repositories WHERE id=$1 AND workspace_id=$2 AND status <> 'deleted'
+`
+
+type GetRepositoryParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, getRepository, arg.ID, arg.WorkspaceID)
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const getRepositoryOperation = `-- name: GetRepositoryOperation :one
+SELECT id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at FROM repository_operations WHERE id=$1
+`
+
+func (q *Queries) GetRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error) {
+ row := q.db.QueryRow(ctx, getRepositoryOperation, id)
+ var i RepositoryOperation
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getSnapshot = `-- name: GetSnapshot :one
+SELECT id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state, purge_cleanup_id, created_at FROM commit_snapshots WHERE id=$1 AND repository_id=$2
+`
+
+type GetSnapshotParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+}
+
+func (q *Queries) GetSnapshot(ctx context.Context, arg GetSnapshotParams) (CommitSnapshot, error) {
+ row := q.db.QueryRow(ctx, getSnapshot, arg.ID, arg.RepositoryID)
+ var i CommitSnapshot
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.GitRef,
+ &i.AuthorName,
+ &i.AuthoredAt,
+ &i.Title,
+ &i.SourceState,
+ &i.PurgeCleanupID,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const getWorkspaceSnapshot = `-- name: GetWorkspaceSnapshot :one
+SELECT s.id, s.repository_id, s.commit_sha, s.source_ref, s.git_ref, s.author_name, s.authored_at, s.title, s.source_state, s.purge_cleanup_id, s.created_at FROM commit_snapshots s
+JOIN repositories r ON r.id=s.repository_id
+WHERE s.id=$1 AND s.repository_id=$2 AND r.workspace_id=$3
+`
+
+type GetWorkspaceSnapshotParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) GetWorkspaceSnapshot(ctx context.Context, arg GetWorkspaceSnapshotParams) (CommitSnapshot, error) {
+ row := q.db.QueryRow(ctx, getWorkspaceSnapshot, arg.ID, arg.RepositoryID, arg.WorkspaceID)
+ var i CommitSnapshot
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.GitRef,
+ &i.AuthorName,
+ &i.AuthoredAt,
+ &i.Title,
+ &i.SourceState,
+ &i.PurgeCleanupID,
+ &i.CreatedAt,
+ )
+ return i, err
+}
+
+const hasRepositoryAnalysisRuns = `-- name: HasRepositoryAnalysisRuns :one
+SELECT EXISTS (SELECT 1 FROM analysis_runs WHERE repository_id=$1)
+`
+
+func (q *Queries) HasRepositoryAnalysisRuns(ctx context.Context, repositoryID uuid.UUID) (bool, error) {
+ row := q.db.QueryRow(ctx, hasRepositoryAnalysisRuns, repositoryID)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
+
+const listRepositories = `-- name: ListRepositories :many
+SELECT r.id, r.workspace_id, r.provider_type, r.name, r.remote_url, r.normalized_url, r.configured_ref, r.config_version, r.git_path, r.status, r.current_snapshot_id, r.mirror_size_bytes, r.last_sync_at, r.last_error_code, r.last_error_message, r.archived_at, r.deleted_at, r.created_by, r.created_at, r.updated_at, s.commit_sha AS current_commit_sha, s.author_name AS current_author_name,
+ s.source_ref AS current_source_ref, s.authored_at AS current_authored_at, s.title AS current_title,
+ s.source_state AS current_source_state, s.created_at AS snapshot_created_at
+FROM repositories r LEFT JOIN commit_snapshots s ON s.id=r.current_snapshot_id
+WHERE r.workspace_id=$1
+ AND r.status <> 'deleted'
+ AND (
+ $2::timestamptz IS NULL
+ OR (r.created_at, r.id) < ($2, $3::uuid)
+ )
+ORDER BY r.created_at DESC, r.id DESC
+LIMIT $4
+`
+
+type ListRepositoriesParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+type ListRepositoriesRow struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ProviderType string `json:"provider_type"`
+ Name string `json:"name"`
+ RemoteUrl string `json:"remote_url"`
+ NormalizedUrl string `json:"normalized_url"`
+ ConfiguredRef string `json:"configured_ref"`
+ ConfigVersion int64 `json:"config_version"`
+ GitPath string `json:"git_path"`
+ Status string `json:"status"`
+ CurrentSnapshotID uuid.NullUUID `json:"current_snapshot_id"`
+ MirrorSizeBytes int64 `json:"mirror_size_bytes"`
+ LastSyncAt pgtype.Timestamptz `json:"last_sync_at"`
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+ ArchivedAt pgtype.Timestamptz `json:"archived_at"`
+ DeletedAt pgtype.Timestamptz `json:"deleted_at"`
+ CreatedBy uuid.UUID `json:"created_by"`
+ CreatedAt pgtype.Timestamptz `json:"created_at"`
+ UpdatedAt pgtype.Timestamptz `json:"updated_at"`
+ CurrentCommitSha pgtype.Text `json:"current_commit_sha"`
+ CurrentAuthorName pgtype.Text `json:"current_author_name"`
+ CurrentSourceRef pgtype.Text `json:"current_source_ref"`
+ CurrentAuthoredAt pgtype.Timestamptz `json:"current_authored_at"`
+ CurrentTitle pgtype.Text `json:"current_title"`
+ CurrentSourceState pgtype.Text `json:"current_source_state"`
+ SnapshotCreatedAt pgtype.Timestamptz `json:"snapshot_created_at"`
+}
+
+func (q *Queries) ListRepositories(ctx context.Context, arg ListRepositoriesParams) ([]ListRepositoriesRow, error) {
+ rows, err := q.db.Query(ctx, listRepositories,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ListRepositoriesRow{}
+ for rows.Next() {
+ var i ListRepositoriesRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ &i.CurrentCommitSha,
+ &i.CurrentAuthorName,
+ &i.CurrentSourceRef,
+ &i.CurrentAuthoredAt,
+ &i.CurrentTitle,
+ &i.CurrentSourceState,
+ &i.SnapshotCreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listRepositoryOperations = `-- name: ListRepositoryOperations :many
+SELECT id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at FROM repository_operations
+WHERE repository_id=$1
+ AND (
+ $2::timestamptz IS NULL
+ OR (created_at, id) < ($2, $3::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT $4
+`
+
+type ListRepositoryOperationsParams struct {
+ RepositoryID uuid.UUID `json:"repository_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ListRepositoryOperations(ctx context.Context, arg ListRepositoryOperationsParams) ([]RepositoryOperation, error) {
+ rows, err := q.db.Query(ctx, listRepositoryOperations,
+ arg.RepositoryID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []RepositoryOperation{}
+ for rows.Next() {
+ var i RepositoryOperation
+ if err := rows.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const listRepositorySnapshots = `-- name: ListRepositorySnapshots :many
+SELECT s.id, s.repository_id, s.commit_sha, s.source_ref, s.git_ref, s.author_name, s.authored_at, s.title, s.source_state, s.purge_cleanup_id, s.created_at FROM commit_snapshots s
+JOIN repositories r ON r.id=s.repository_id
+WHERE s.repository_id=$1 AND r.workspace_id=$2
+ AND (
+ $3::timestamptz IS NULL
+ OR (s.created_at, s.id) < ($3, $4::uuid)
+ )
+ORDER BY s.created_at DESC, s.id DESC
+LIMIT $5
+`
+
+type ListRepositorySnapshotsParams struct {
+ RepositoryID uuid.UUID `json:"repository_id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CursorTime pgtype.Timestamptz `json:"cursor_time"`
+ CursorID uuid.NullUUID `json:"cursor_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ListRepositorySnapshots(ctx context.Context, arg ListRepositorySnapshotsParams) ([]CommitSnapshot, error) {
+ rows, err := q.db.Query(ctx, listRepositorySnapshots,
+ arg.RepositoryID,
+ arg.WorkspaceID,
+ arg.CursorTime,
+ arg.CursorID,
+ arg.Limit,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []CommitSnapshot{}
+ for rows.Next() {
+ var i CommitSnapshot
+ if err := rows.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.CommitSha,
+ &i.SourceRef,
+ &i.GitRef,
+ &i.AuthorName,
+ &i.AuthoredAt,
+ &i.Title,
+ &i.SourceState,
+ &i.PurgeCleanupID,
+ &i.CreatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const markSnapshotsPurged = `-- name: MarkSnapshotsPurged :exec
+UPDATE commit_snapshots SET source_state='purged' WHERE repository_id=$1
+`
+
+func (q *Queries) MarkSnapshotsPurged(ctx context.Context, repositoryID uuid.UUID) error {
+ _, err := q.db.Exec(ctx, markSnapshotsPurged, repositoryID)
+ return err
+}
+
+const projectRepositoryOperationFailure = `-- name: ProjectRepositoryOperationFailure :execrows
+UPDATE repositories
+SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END,
+ provider_type=$1,
+ remote_url=$2,
+ normalized_url=$3,
+ configured_ref=$4,
+ last_error_code=$5, last_error_message=$6, updated_at=now()
+WHERE id=$7 AND config_version=$8 AND status <> 'deleted'
+`
+
+type ProjectRepositoryOperationFailureParams struct {
+ PreviousProviderType string `json:"previous_provider_type"`
+ PreviousRemoteUrl string `json:"previous_remote_url"`
+ PreviousNormalizedUrl string `json:"previous_normalized_url"`
+ PreviousRef string `json:"previous_ref"`
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+ ID uuid.UUID `json:"id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) ProjectRepositoryOperationFailure(ctx context.Context, arg ProjectRepositoryOperationFailureParams) (int64, error) {
+ result, err := q.db.Exec(ctx, projectRepositoryOperationFailure,
+ arg.PreviousProviderType,
+ arg.PreviousRemoteUrl,
+ arg.PreviousNormalizedUrl,
+ arg.PreviousRef,
+ arg.LastErrorCode,
+ arg.LastErrorMessage,
+ arg.ID,
+ arg.ConfigVersion,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const releaseRepositorySource = `-- name: ReleaseRepositorySource :execrows
+DELETE FROM repository_source_keys
+WHERE workspace_id=$1 AND normalized_url=$2 AND repository_id=$3
+`
+
+type ReleaseRepositorySourceParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NormalizedUrl string `json:"normalized_url"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+}
+
+func (q *Queries) ReleaseRepositorySource(ctx context.Context, arg ReleaseRepositorySourceParams) (int64, error) {
+ result, err := q.db.Exec(ctx, releaseRepositorySource, arg.WorkspaceID, arg.NormalizedUrl, arg.RepositoryID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const requestRepositoryDeletion = `-- name: RequestRepositoryDeletion :one
+UPDATE repositories SET status='deleting', updated_at=now()
+WHERE id=$1 AND workspace_id=$2
+ AND status NOT IN ('provisioning','syncing','deleting','deleted')
+RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type RequestRepositoryDeletionParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) RequestRepositoryDeletion(ctx context.Context, arg RequestRepositoryDeletionParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, requestRepositoryDeletion, arg.ID, arg.WorkspaceID)
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const reserveRepositorySource = `-- name: ReserveRepositorySource :one
+INSERT INTO repository_source_keys (workspace_id, normalized_url, repository_id)
+VALUES ($1,$2,$3)
+ON CONFLICT (workspace_id, normalized_url) DO UPDATE
+SET normalized_url=excluded.normalized_url
+WHERE repository_source_keys.repository_id=excluded.repository_id
+RETURNING repository_id
+`
+
+type ReserveRepositorySourceParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NormalizedUrl string `json:"normalized_url"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+}
+
+func (q *Queries) ReserveRepositorySource(ctx context.Context, arg ReserveRepositorySourceParams) (uuid.UUID, error) {
+ row := q.db.QueryRow(ctx, reserveRepositorySource, arg.WorkspaceID, arg.NormalizedUrl, arg.RepositoryID)
+ var repository_id uuid.UUID
+ err := row.Scan(&repository_id)
+ return repository_id, err
+}
+
+const restoreDeletedRepositoryAfterPurgeFailure = `-- name: RestoreDeletedRepositoryAfterPurgeFailure :execrows
+UPDATE repositories
+SET status='deleted', last_error_code=$1,
+ last_error_message=$2, updated_at=now()
+WHERE id=$3 AND config_version=$4
+ AND deleted_at IS NOT NULL AND status='deleting'
+`
+
+type RestoreDeletedRepositoryAfterPurgeFailureParams struct {
+ LastErrorCode pgtype.Text `json:"last_error_code"`
+ LastErrorMessage pgtype.Text `json:"last_error_message"`
+ ID uuid.UUID `json:"id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) RestoreDeletedRepositoryAfterPurgeFailure(ctx context.Context, arg RestoreDeletedRepositoryAfterPurgeFailureParams) (int64, error) {
+ result, err := q.db.Exec(ctx, restoreDeletedRepositoryAfterPurgeFailure,
+ arg.LastErrorCode,
+ arg.LastErrorMessage,
+ arg.ID,
+ arg.ConfigVersion,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const restoreRepository = `-- name: RestoreRepository :one
+UPDATE repositories SET archived_at=NULL, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type RestoreRepositoryParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+func (q *Queries) RestoreRepository(ctx context.Context, arg RestoreRepositoryParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, restoreRepository, arg.ID, arg.WorkspaceID)
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const restoreRepositoryAfterOperation = `-- name: RestoreRepositoryAfterOperation :execrows
+UPDATE repositories
+SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END,
+ provider_type=$1,
+ remote_url=$2,
+ normalized_url=$3,
+ configured_ref=$4,
+ updated_at=now()
+WHERE id=$5 AND config_version=$6 AND status IN ('syncing','deleting')
+`
+
+type RestoreRepositoryAfterOperationParams struct {
+ PreviousProviderType string `json:"previous_provider_type"`
+ PreviousRemoteUrl string `json:"previous_remote_url"`
+ PreviousNormalizedUrl string `json:"previous_normalized_url"`
+ PreviousRef string `json:"previous_ref"`
+ ID uuid.UUID `json:"id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) RestoreRepositoryAfterOperation(ctx context.Context, arg RestoreRepositoryAfterOperationParams) (int64, error) {
+ result, err := q.db.Exec(ctx, restoreRepositoryAfterOperation,
+ arg.PreviousProviderType,
+ arg.PreviousRemoteUrl,
+ arg.PreviousNormalizedUrl,
+ arg.PreviousRef,
+ arg.ID,
+ arg.ConfigVersion,
+ )
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const setRepositoryCurrentSnapshot = `-- name: SetRepositoryCurrentSnapshot :one
+UPDATE repositories
+SET current_snapshot_id=$1,
+ provider_type=$2, remote_url=$3,
+ normalized_url=$4, configured_ref=$5,
+ status='ready', mirror_size_bytes=$6,
+ last_sync_at=now(), last_error_code=NULL, last_error_message=NULL, updated_at=now()
+WHERE id=$7 AND workspace_id=$8
+ AND config_version=$9 RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type SetRepositoryCurrentSnapshotParams struct {
+ CurrentSnapshotID uuid.NullUUID `json:"current_snapshot_id"`
+ ProviderType string `json:"provider_type"`
+ RemoteUrl string `json:"remote_url"`
+ NormalizedUrl string `json:"normalized_url"`
+ ConfiguredRef string `json:"configured_ref"`
+ MirrorSizeBytes int64 `json:"mirror_size_bytes"`
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) SetRepositoryCurrentSnapshot(ctx context.Context, arg SetRepositoryCurrentSnapshotParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, setRepositoryCurrentSnapshot,
+ arg.CurrentSnapshotID,
+ arg.ProviderType,
+ arg.RemoteUrl,
+ arg.NormalizedUrl,
+ arg.ConfiguredRef,
+ arg.MirrorSizeBytes,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ConfigVersion,
+ )
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const setRepositoryOperationWorkflowID = `-- name: SetRepositoryOperationWorkflowID :exec
+UPDATE repository_operations SET workflow_run_id=$2 WHERE id=$1
+`
+
+type SetRepositoryOperationWorkflowIDParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+}
+
+func (q *Queries) SetRepositoryOperationWorkflowID(ctx context.Context, arg SetRepositoryOperationWorkflowIDParams) error {
+ _, err := q.db.Exec(ctx, setRepositoryOperationWorkflowID, arg.ID, arg.WorkflowRunID)
+ return err
+}
+
+const setRepositoryStatus = `-- name: SetRepositoryStatus :exec
+UPDATE repositories SET status=$2, updated_at=now() WHERE id=$1
+`
+
+type SetRepositoryStatusParams struct {
+ ID uuid.UUID `json:"id"`
+ Status string `json:"status"`
+}
+
+func (q *Queries) SetRepositoryStatus(ctx context.Context, arg SetRepositoryStatusParams) error {
+ _, err := q.db.Exec(ctx, setRepositoryStatus, arg.ID, arg.Status)
+ return err
+}
+
+const setRepositorySyncing = `-- name: SetRepositorySyncing :execrows
+UPDATE repositories
+SET status='syncing', updated_at=now()
+WHERE id=$1 AND config_version=$2 AND status NOT IN ('deleting','deleted')
+`
+
+type SetRepositorySyncingParams struct {
+ ID uuid.UUID `json:"id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) SetRepositorySyncing(ctx context.Context, arg SetRepositorySyncingParams) (int64, error) {
+ result, err := q.db.Exec(ctx, setRepositorySyncing, arg.ID, arg.ConfigVersion)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const stageRepositoryUpdate = `-- name: StageRepositoryUpdate :one
+UPDATE repositories
+SET name=$1, provider_type=$2,
+ remote_url=$3, normalized_url=$4,
+ configured_ref=$5, config_version=config_version+1, status='syncing',
+ updated_at=now()
+WHERE id=$6 AND workspace_id=$7
+ AND config_version=$8
+ AND status NOT IN ('provisioning','syncing','deleting','deleted')
+RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type StageRepositoryUpdateParams struct {
+ Name string `json:"name"`
+ ProviderType string `json:"provider_type"`
+ RemoteUrl string `json:"remote_url"`
+ NormalizedUrl string `json:"normalized_url"`
+ ConfiguredRef string `json:"configured_ref"`
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ConfigVersion int64 `json:"config_version"`
+}
+
+func (q *Queries) StageRepositoryUpdate(ctx context.Context, arg StageRepositoryUpdateParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, stageRepositoryUpdate,
+ arg.Name,
+ arg.ProviderType,
+ arg.RemoteUrl,
+ arg.NormalizedUrl,
+ arg.ConfiguredRef,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ConfigVersion,
+ )
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
+
+const startRepositoryOperation = `-- name: StartRepositoryOperation :one
+UPDATE repository_operations SET status='running', started_at=now()
+WHERE id=$1 AND status='queued' RETURNING id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind, requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref, previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status, outcome, resolved_commit_sha, snapshot_id, workflow_run_id, error_message, created_at, started_at, finished_at
+`
+
+func (q *Queries) StartRepositoryOperation(ctx context.Context, id uuid.UUID) (RepositoryOperation, error) {
+ row := q.db.QueryRow(ctx, startRepositoryOperation, id)
+ var i RepositoryOperation
+ err := row.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.ActorUserID,
+ &i.ProviderConnectionID,
+ &i.CredentialVersion,
+ &i.RepositoryVersion,
+ &i.Kind,
+ &i.RequestedProviderType,
+ &i.RequestedRemoteUrl,
+ &i.RequestedNormalizedUrl,
+ &i.RequestedRef,
+ &i.PreviousProviderType,
+ &i.PreviousRemoteUrl,
+ &i.PreviousNormalizedUrl,
+ &i.PreviousRef,
+ &i.Status,
+ &i.Outcome,
+ &i.ResolvedCommitSha,
+ &i.SnapshotID,
+ &i.WorkflowRunID,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const updateRepository = `-- name: UpdateRepository :one
+UPDATE repositories SET name=$3, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 RETURNING id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, config_version, git_path, status, current_snapshot_id, mirror_size_bytes, last_sync_at, last_error_code, last_error_message, archived_at, deleted_at, created_by, created_at, updated_at
+`
+
+type UpdateRepositoryParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Name string `json:"name"`
+}
+
+func (q *Queries) UpdateRepository(ctx context.Context, arg UpdateRepositoryParams) (Repository, error) {
+ row := q.db.QueryRow(ctx, updateRepository, arg.ID, arg.WorkspaceID, arg.Name)
+ var i Repository
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ )
+ return i, err
+}
diff --git a/internal/data/sqlc/retention.sql.go b/internal/data/sqlc/retention.sql.go
new file mode 100644
index 0000000..98d5748
--- /dev/null
+++ b/internal/data/sqlc/retention.sql.go
@@ -0,0 +1,527 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: retention.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const addRequeuedRepositoryCount = `-- name: AddRequeuedRepositoryCount :execrows
+UPDATE retention_cleanups
+SET requeued_repository_count = requeued_repository_count + $2
+WHERE id = $1 AND status = 'running'
+`
+
+type AddRequeuedRepositoryCountParams struct {
+ ID uuid.UUID `json:"id"`
+ RequeuedRepositoryCount int32 `json:"requeued_repository_count"`
+}
+
+func (q *Queries) AddRequeuedRepositoryCount(ctx context.Context, arg AddRequeuedRepositoryCountParams) (int64, error) {
+ result, err := q.db.Exec(ctx, addRequeuedRepositoryCount, arg.ID, arg.RequeuedRepositoryCount)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const addRetentionCleanupProgress = `-- name: AddRetentionCleanupProgress :execrows
+UPDATE retention_cleanups
+SET deleted_run_count = deleted_run_count + $1::integer,
+ purged_snapshot_count = purged_snapshot_count + $2::integer
+WHERE id = $3 AND status = 'running'
+`
+
+type AddRetentionCleanupProgressParams struct {
+ DeletedRuns int32 `json:"deleted_runs"`
+ PurgedSnapshots int32 `json:"purged_snapshots"`
+ ID uuid.UUID `json:"id"`
+}
+
+func (q *Queries) AddRetentionCleanupProgress(ctx context.Context, arg AddRetentionCleanupProgressParams) (int64, error) {
+ result, err := q.db.Exec(ctx, addRetentionCleanupProgress, arg.DeletedRuns, arg.PurgedSnapshots, arg.ID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const advanceRetentionSchedule = `-- name: AdvanceRetentionSchedule :execrows
+UPDATE retention_cleanup_schedules
+SET last_run_at = clock_timestamp(),
+ next_run_at = GREATEST(next_run_at + INTERVAL '1 day', clock_timestamp() + INTERVAL '1 day'),
+ updated_at = clock_timestamp()
+WHERE workspace_id = $1 AND next_run_at = $2
+`
+
+type AdvanceRetentionScheduleParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NextRunAt pgtype.Timestamptz `json:"next_run_at"`
+}
+
+func (q *Queries) AdvanceRetentionSchedule(ctx context.Context, arg AdvanceRetentionScheduleParams) (int64, error) {
+ result, err := q.db.Exec(ctx, advanceRetentionSchedule, arg.WorkspaceID, arg.NextRunAt)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const claimDueRetentionSchedules = `-- name: ClaimDueRetentionSchedules :many
+SELECT schedule.workspace_id, schedule.next_run_at, workspace.report_retention_days
+FROM retention_cleanup_schedules AS schedule
+JOIN workspaces AS workspace ON workspace.id = schedule.workspace_id
+WHERE schedule.next_run_at <= clock_timestamp()
+ORDER BY schedule.next_run_at, schedule.workspace_id
+FOR UPDATE OF schedule SKIP LOCKED
+LIMIT $1
+`
+
+type ClaimDueRetentionSchedulesRow struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ NextRunAt pgtype.Timestamptz `json:"next_run_at"`
+ ReportRetentionDays int32 `json:"report_retention_days"`
+}
+
+func (q *Queries) ClaimDueRetentionSchedules(ctx context.Context, limit int32) ([]ClaimDueRetentionSchedulesRow, error) {
+ rows, err := q.db.Query(ctx, claimDueRetentionSchedules, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []ClaimDueRetentionSchedulesRow{}
+ for rows.Next() {
+ var i ClaimDueRetentionSchedulesRow
+ if err := rows.Scan(&i.WorkspaceID, &i.NextRunAt, &i.ReportRetentionDays); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const claimRepositoriesReadyForPurge = `-- name: ClaimRepositoriesReadyForPurge :many
+SELECT repository.id, repository.workspace_id, repository.provider_type, repository.name, repository.remote_url, repository.normalized_url, repository.configured_ref, repository.config_version, repository.git_path, repository.status, repository.current_snapshot_id, repository.mirror_size_bytes, repository.last_sync_at, repository.last_error_code, repository.last_error_message, repository.archived_at, repository.deleted_at, repository.created_by, repository.created_at, repository.updated_at
+FROM repositories AS repository
+WHERE repository.workspace_id = $1
+ AND repository.status = 'deleted'
+ AND NOT EXISTS (
+ SELECT 1 FROM analysis_runs AS run WHERE run.repository_id = repository.id
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM repository_operations AS operation
+ WHERE operation.repository_id = repository.id
+ AND operation.kind = 'purge'
+ AND operation.status IN ('queued', 'running')
+ )
+ORDER BY repository.deleted_at, repository.id
+FOR UPDATE OF repository SKIP LOCKED
+LIMIT $2
+`
+
+type ClaimRepositoriesReadyForPurgeParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ClaimRepositoriesReadyForPurge(ctx context.Context, arg ClaimRepositoriesReadyForPurgeParams) ([]Repository, error) {
+ rows, err := q.db.Query(ctx, claimRepositoriesReadyForPurge, arg.WorkspaceID, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []Repository{}
+ for rows.Next() {
+ var i Repository
+ if err := rows.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ProviderType,
+ &i.Name,
+ &i.RemoteUrl,
+ &i.NormalizedUrl,
+ &i.ConfiguredRef,
+ &i.ConfigVersion,
+ &i.GitPath,
+ &i.Status,
+ &i.CurrentSnapshotID,
+ &i.MirrorSizeBytes,
+ &i.LastSyncAt,
+ &i.LastErrorCode,
+ &i.LastErrorMessage,
+ &i.ArchivedAt,
+ &i.DeletedAt,
+ &i.CreatedBy,
+ &i.CreatedAt,
+ &i.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const completeSnapshotPurge = `-- name: CompleteSnapshotPurge :execrows
+UPDATE commit_snapshots
+SET source_state = 'purged', purge_cleanup_id = NULL
+WHERE id = $1 AND repository_id = $2 AND purge_cleanup_id = $3 AND source_state = 'purging'
+`
+
+type CompleteSnapshotPurgeParams struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ PurgeCleanupID uuid.NullUUID `json:"purge_cleanup_id"`
+}
+
+func (q *Queries) CompleteSnapshotPurge(ctx context.Context, arg CompleteSnapshotPurgeParams) (int64, error) {
+ result, err := q.db.Exec(ctx, completeSnapshotPurge, arg.ID, arg.RepositoryID, arg.PurgeCleanupID)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const createRetentionCleanup = `-- name: CreateRetentionCleanup :one
+INSERT INTO retention_cleanups (id, workspace_id, scheduled_for, retention_days, status)
+VALUES ($1, $2, $3, $4, 'queued')
+RETURNING id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at
+`
+
+type CreateRetentionCleanupParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ ScheduledFor pgtype.Timestamptz `json:"scheduled_for"`
+ RetentionDays int32 `json:"retention_days"`
+}
+
+func (q *Queries) CreateRetentionCleanup(ctx context.Context, arg CreateRetentionCleanupParams) (RetentionCleanup, error) {
+ row := q.db.QueryRow(ctx, createRetentionCleanup,
+ arg.ID,
+ arg.WorkspaceID,
+ arg.ScheduledFor,
+ arg.RetentionDays,
+ )
+ var i RetentionCleanup
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ScheduledFor,
+ &i.RetentionDays,
+ &i.Status,
+ &i.WorkflowRunID,
+ &i.DeletedRunCount,
+ &i.PurgedSnapshotCount,
+ &i.RequeuedRepositoryCount,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const deleteExpiredAnalysisRuns = `-- name: DeleteExpiredAnalysisRuns :many
+WITH candidates AS (
+ SELECT analysis_run.id
+ FROM analysis_runs AS analysis_run
+ WHERE analysis_run.workspace_id = $1
+ AND analysis_run.status IN ('succeeded', 'failed', 'cancelled')
+ AND analysis_run.finished_at < clock_timestamp() - make_interval(days => $2::integer)
+ ORDER BY analysis_run.finished_at, analysis_run.id
+ FOR UPDATE SKIP LOCKED
+ LIMIT $3
+)
+DELETE FROM analysis_runs AS run
+USING candidates
+WHERE run.id = candidates.id
+RETURNING run.snapshot_id
+`
+
+type DeleteExpiredAnalysisRunsParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ RetentionDays int32 `json:"retention_days"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) DeleteExpiredAnalysisRuns(ctx context.Context, arg DeleteExpiredAnalysisRunsParams) ([]uuid.UUID, error) {
+ rows, err := q.db.Query(ctx, deleteExpiredAnalysisRuns, arg.WorkspaceID, arg.RetentionDays, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []uuid.UUID{}
+ for rows.Next() {
+ var snapshot_id uuid.UUID
+ if err := rows.Scan(&snapshot_id); err != nil {
+ return nil, err
+ }
+ items = append(items, snapshot_id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const failRetentionCleanup = `-- name: FailRetentionCleanup :execrows
+UPDATE retention_cleanups
+SET status = 'failed', error_message = $2, finished_at = clock_timestamp()
+WHERE id = $1 AND status IN ('queued', 'running')
+`
+
+type FailRetentionCleanupParams struct {
+ ID uuid.UUID `json:"id"`
+ ErrorMessage pgtype.Text `json:"error_message"`
+}
+
+func (q *Queries) FailRetentionCleanup(ctx context.Context, arg FailRetentionCleanupParams) (int64, error) {
+ result, err := q.db.Exec(ctx, failRetentionCleanup, arg.ID, arg.ErrorMessage)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const finishRetentionCleanup = `-- name: FinishRetentionCleanup :one
+UPDATE retention_cleanups
+SET status = 'succeeded', finished_at = clock_timestamp(), error_message = NULL
+WHERE id = $1 AND status = 'running'
+RETURNING id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at
+`
+
+func (q *Queries) FinishRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) {
+ row := q.db.QueryRow(ctx, finishRetentionCleanup, id)
+ var i RetentionCleanup
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ScheduledFor,
+ &i.RetentionDays,
+ &i.Status,
+ &i.WorkflowRunID,
+ &i.DeletedRunCount,
+ &i.PurgedSnapshotCount,
+ &i.RequeuedRepositoryCount,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const getRetentionCleanup = `-- name: GetRetentionCleanup :one
+SELECT id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at FROM retention_cleanups WHERE id = $1
+`
+
+func (q *Queries) GetRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) {
+ row := q.db.QueryRow(ctx, getRetentionCleanup, id)
+ var i RetentionCleanup
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ScheduledFor,
+ &i.RetentionDays,
+ &i.Status,
+ &i.WorkflowRunID,
+ &i.DeletedRunCount,
+ &i.PurgedSnapshotCount,
+ &i.RequeuedRepositoryCount,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
+
+const lockPurgeableSnapshotCandidates = `-- name: LockPurgeableSnapshotCandidates :many
+SELECT snapshot.id
+FROM commit_snapshots AS snapshot
+JOIN repositories AS repository ON repository.id = snapshot.repository_id
+WHERE repository.workspace_id = $1
+ AND (
+ snapshot.source_state = 'available'
+ OR (
+ snapshot.source_state = 'purging'
+ AND (
+ snapshot.purge_cleanup_id = $2
+ OR EXISTS (
+ SELECT 1
+ FROM retention_cleanups AS previous_cleanup
+ WHERE previous_cleanup.id = snapshot.purge_cleanup_id
+ AND previous_cleanup.status = 'failed'
+ )
+ )
+ )
+ )
+ AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id
+ AND NOT EXISTS (
+ SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id
+ )
+ORDER BY snapshot.created_at, snapshot.id
+FOR UPDATE OF snapshot SKIP LOCKED
+LIMIT $3
+`
+
+type LockPurgeableSnapshotCandidatesParams struct {
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+ CleanupID uuid.NullUUID `json:"cleanup_id"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) LockPurgeableSnapshotCandidates(ctx context.Context, arg LockPurgeableSnapshotCandidatesParams) ([]uuid.UUID, error) {
+ rows, err := q.db.Query(ctx, lockPurgeableSnapshotCandidates, arg.WorkspaceID, arg.CleanupID, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []uuid.UUID{}
+ for rows.Next() {
+ var id uuid.UUID
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ items = append(items, id)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const markSnapshotsPurging = `-- name: MarkSnapshotsPurging :many
+UPDATE commit_snapshots AS snapshot
+SET source_state = 'purging', purge_cleanup_id = $1
+FROM repositories AS repository
+WHERE snapshot.id = ANY($2::uuid[])
+ AND repository.id = snapshot.repository_id
+ AND repository.workspace_id = $3
+ AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id
+ AND NOT EXISTS (
+ SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id
+ )
+ AND (
+ snapshot.source_state = 'available'
+ OR (
+ snapshot.source_state = 'purging'
+ AND (
+ snapshot.purge_cleanup_id = $1
+ OR EXISTS (
+ SELECT 1
+ FROM retention_cleanups AS previous_cleanup
+ WHERE previous_cleanup.id = snapshot.purge_cleanup_id
+ AND previous_cleanup.status = 'failed'
+ )
+ )
+ )
+ )
+RETURNING snapshot.id, snapshot.repository_id, snapshot.git_ref, snapshot.commit_sha
+`
+
+type MarkSnapshotsPurgingParams struct {
+ CleanupID uuid.NullUUID `json:"cleanup_id"`
+ SnapshotIds []uuid.UUID `json:"snapshot_ids"`
+ WorkspaceID uuid.UUID `json:"workspace_id"`
+}
+
+type MarkSnapshotsPurgingRow struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repository_id"`
+ GitRef string `json:"git_ref"`
+ CommitSha string `json:"commit_sha"`
+}
+
+func (q *Queries) MarkSnapshotsPurging(ctx context.Context, arg MarkSnapshotsPurgingParams) ([]MarkSnapshotsPurgingRow, error) {
+ rows, err := q.db.Query(ctx, markSnapshotsPurging, arg.CleanupID, arg.SnapshotIds, arg.WorkspaceID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []MarkSnapshotsPurgingRow{}
+ for rows.Next() {
+ var i MarkSnapshotsPurgingRow
+ if err := rows.Scan(
+ &i.ID,
+ &i.RepositoryID,
+ &i.GitRef,
+ &i.CommitSha,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const reactivateRepositoryPurge = `-- name: ReactivateRepositoryPurge :execrows
+UPDATE repositories
+SET status = 'deleting', updated_at = clock_timestamp()
+WHERE id = $1 AND status = 'deleted'
+`
+
+func (q *Queries) ReactivateRepositoryPurge(ctx context.Context, id uuid.UUID) (int64, error) {
+ result, err := q.db.Exec(ctx, reactivateRepositoryPurge, id)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
+
+const setRetentionCleanupWorkflowID = `-- name: SetRetentionCleanupWorkflowID :exec
+UPDATE retention_cleanups SET workflow_run_id = $2 WHERE id = $1
+`
+
+type SetRetentionCleanupWorkflowIDParams struct {
+ ID uuid.UUID `json:"id"`
+ WorkflowRunID pgtype.Text `json:"workflow_run_id"`
+}
+
+func (q *Queries) SetRetentionCleanupWorkflowID(ctx context.Context, arg SetRetentionCleanupWorkflowIDParams) error {
+ _, err := q.db.Exec(ctx, setRetentionCleanupWorkflowID, arg.ID, arg.WorkflowRunID)
+ return err
+}
+
+const startRetentionCleanup = `-- name: StartRetentionCleanup :one
+UPDATE retention_cleanups
+SET status = 'running', started_at = COALESCE(started_at, clock_timestamp()), error_message = NULL
+WHERE id = $1 AND status IN ('queued', 'running')
+RETURNING id, workspace_id, scheduled_for, retention_days, status, workflow_run_id, deleted_run_count, purged_snapshot_count, requeued_repository_count, error_message, created_at, started_at, finished_at
+`
+
+func (q *Queries) StartRetentionCleanup(ctx context.Context, id uuid.UUID) (RetentionCleanup, error) {
+ row := q.db.QueryRow(ctx, startRetentionCleanup, id)
+ var i RetentionCleanup
+ err := row.Scan(
+ &i.ID,
+ &i.WorkspaceID,
+ &i.ScheduledFor,
+ &i.RetentionDays,
+ &i.Status,
+ &i.WorkflowRunID,
+ &i.DeletedRunCount,
+ &i.PurgedSnapshotCount,
+ &i.RequeuedRepositoryCount,
+ &i.ErrorMessage,
+ &i.CreatedAt,
+ &i.StartedAt,
+ &i.FinishedAt,
+ )
+ return i, err
+}
diff --git a/internal/data/sqlc/workflows.sql.go b/internal/data/sqlc/workflows.sql.go
new file mode 100644
index 0000000..aaed2f7
--- /dev/null
+++ b/internal/data/sqlc/workflows.sql.go
@@ -0,0 +1,160 @@
+// Code generated by sqlc. DO NOT EDIT.
+// versions:
+// sqlc v1.29.0
+// source: workflows.sql
+
+package sqlc
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+const cancelWorkflowDispatch = `-- name: CancelWorkflowDispatch :exec
+UPDATE workflow_dispatches SET status='cancelled'
+WHERE aggregate_type=$1 AND aggregate_id=$2 AND status='pending'
+`
+
+type CancelWorkflowDispatchParams struct {
+ AggregateType string `json:"aggregate_type"`
+ AggregateID uuid.UUID `json:"aggregate_id"`
+}
+
+func (q *Queries) CancelWorkflowDispatch(ctx context.Context, arg CancelWorkflowDispatchParams) error {
+ _, err := q.db.Exec(ctx, cancelWorkflowDispatch, arg.AggregateType, arg.AggregateID)
+ return err
+}
+
+const claimWorkflowDispatches = `-- name: ClaimWorkflowDispatches :many
+WITH candidates AS (
+ SELECT id FROM workflow_dispatches
+ WHERE status='pending' AND available_at <= now()
+ ORDER BY created_at
+ FOR UPDATE SKIP LOCKED
+ LIMIT $2
+)
+UPDATE workflow_dispatches AS dispatch
+SET attempts=dispatch.attempts+1,
+ available_at=now() + $1::interval
+FROM candidates
+WHERE dispatch.id=candidates.id
+RETURNING dispatch.id, dispatch.aggregate_type, dispatch.aggregate_id, dispatch.workflow_name, dispatch.payload, dispatch.status, dispatch.attempts, dispatch.available_at, dispatch.last_error, dispatch.created_at, dispatch.dispatched_at
+`
+
+type ClaimWorkflowDispatchesParams struct {
+ LeaseDuration pgtype.Interval `json:"lease_duration"`
+ Limit int32 `json:"limit"`
+}
+
+func (q *Queries) ClaimWorkflowDispatches(ctx context.Context, arg ClaimWorkflowDispatchesParams) ([]WorkflowDispatch, error) {
+ rows, err := q.db.Query(ctx, claimWorkflowDispatches, arg.LeaseDuration, arg.Limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []WorkflowDispatch{}
+ for rows.Next() {
+ var i WorkflowDispatch
+ if err := rows.Scan(
+ &i.ID,
+ &i.AggregateType,
+ &i.AggregateID,
+ &i.WorkflowName,
+ &i.Payload,
+ &i.Status,
+ &i.Attempts,
+ &i.AvailableAt,
+ &i.LastError,
+ &i.CreatedAt,
+ &i.DispatchedAt,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+const createWorkflowDispatch = `-- name: CreateWorkflowDispatch :one
+INSERT INTO workflow_dispatches (id, aggregate_type, aggregate_id, workflow_name, payload, status)
+VALUES ($1,$2,$3,$4,$5,'pending') RETURNING id, aggregate_type, aggregate_id, workflow_name, payload, status, attempts, available_at, last_error, created_at, dispatched_at
+`
+
+type CreateWorkflowDispatchParams struct {
+ ID uuid.UUID `json:"id"`
+ AggregateType string `json:"aggregate_type"`
+ AggregateID uuid.UUID `json:"aggregate_id"`
+ WorkflowName string `json:"workflow_name"`
+ Payload []byte `json:"payload"`
+}
+
+func (q *Queries) CreateWorkflowDispatch(ctx context.Context, arg CreateWorkflowDispatchParams) (WorkflowDispatch, error) {
+ row := q.db.QueryRow(ctx, createWorkflowDispatch,
+ arg.ID,
+ arg.AggregateType,
+ arg.AggregateID,
+ arg.WorkflowName,
+ arg.Payload,
+ )
+ var i WorkflowDispatch
+ err := row.Scan(
+ &i.ID,
+ &i.AggregateType,
+ &i.AggregateID,
+ &i.WorkflowName,
+ &i.Payload,
+ &i.Status,
+ &i.Attempts,
+ &i.AvailableAt,
+ &i.LastError,
+ &i.CreatedAt,
+ &i.DispatchedAt,
+ )
+ return i, err
+}
+
+const delayWorkflowDispatch = `-- name: DelayWorkflowDispatch :exec
+UPDATE workflow_dispatches
+SET available_at=$2, last_error=$3
+WHERE id=$1 AND status='pending'
+`
+
+type DelayWorkflowDispatchParams struct {
+ ID uuid.UUID `json:"id"`
+ AvailableAt pgtype.Timestamptz `json:"available_at"`
+ LastError pgtype.Text `json:"last_error"`
+}
+
+func (q *Queries) DelayWorkflowDispatch(ctx context.Context, arg DelayWorkflowDispatchParams) error {
+ _, err := q.db.Exec(ctx, delayWorkflowDispatch, arg.ID, arg.AvailableAt, arg.LastError)
+ return err
+}
+
+const isWorkflowDispatchPending = `-- name: IsWorkflowDispatchPending :one
+SELECT EXISTS(SELECT 1 FROM workflow_dispatches WHERE id=$1 AND status='pending')
+`
+
+func (q *Queries) IsWorkflowDispatchPending(ctx context.Context, id uuid.UUID) (bool, error) {
+ row := q.db.QueryRow(ctx, isWorkflowDispatchPending, id)
+ var exists bool
+ err := row.Scan(&exists)
+ return exists, err
+}
+
+const markWorkflowDispatched = `-- name: MarkWorkflowDispatched :execrows
+UPDATE workflow_dispatches SET status='dispatched', dispatched_at=now()
+WHERE id=$1 AND status='pending'
+`
+
+func (q *Queries) MarkWorkflowDispatched(ctx context.Context, id uuid.UUID) (int64, error) {
+ result, err := q.db.Exec(ctx, markWorkflowDispatched, id)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected(), nil
+}
diff --git a/internal/health/state.go b/internal/health/state.go
deleted file mode 100644
index 7231576..0000000
--- a/internal/health/state.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package health
-
-import (
- "sync"
- "sync/atomic"
-)
-
-type State struct {
- ready atomic.Bool
- shuttingDown atomic.Bool
- componentsMu sync.RWMutex
- components map[string]bool
-}
-
-func NewState() *State {
- return &State{components: make(map[string]bool)}
-}
-
-func (s *State) SetReady(ready bool) {
- s.ready.Store(ready)
-}
-
-func (s *State) StartShutdown() {
- s.shuttingDown.Store(true)
- s.ready.Store(false)
-}
-
-func (s *State) Live() bool {
- return true
-}
-
-func (s *State) Ready() bool {
- if !s.ready.Load() || s.shuttingDown.Load() {
- return false
- }
- s.componentsMu.RLock()
- defer s.componentsMu.RUnlock()
- for _, ready := range s.components {
- if !ready {
- return false
- }
- }
- return true
-}
-
-func (s *State) SetComponent(name string, ready bool) {
- s.componentsMu.Lock()
- s.components[name] = ready
- s.componentsMu.Unlock()
-}
diff --git a/internal/identity/biz/model.go b/internal/identity/biz/model.go
new file mode 100644
index 0000000..6020824
--- /dev/null
+++ b/internal/identity/biz/model.go
@@ -0,0 +1,54 @@
+package biz
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type User struct {
+ ID uuid.UUID `json:"id"`
+ DisplayName string `json:"displayName"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ Status string `json:"status"`
+}
+
+type Workspace struct {
+ ID uuid.UUID `json:"id"`
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ ReportRetentionDays int32 `json:"reportRetentionDays"`
+ CreatedBy uuid.UUID `json:"createdBy"`
+ Role string `json:"role"`
+}
+
+type WorkspaceUpdate struct {
+ Name *string
+ ReportRetentionDays *int32
+}
+
+type Member struct {
+ UserID uuid.UUID `json:"userId"`
+ Username string `json:"username"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ Role string `json:"role"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type Invitation struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspaceId"`
+ Email string `json:"email"`
+ Token string `json:"token,omitempty"`
+ ExpiresAt time.Time `json:"expiresAt"`
+ AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
+ RevokedAt *time.Time `json:"revokedAt,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type Membership struct {
+ Workspace Workspace
+ Role string
+}
diff --git a/internal/identity/biz/provider.go b/internal/identity/biz/provider.go
new file mode 100644
index 0000000..c226b4d
--- /dev/null
+++ b/internal/identity/biz/provider.go
@@ -0,0 +1,259 @@
+package biz
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/fuchencong/mooncode/internal/platform/secret"
+ "github.com/google/uuid"
+)
+
+type ProviderConnection struct {
+ ID uuid.UUID `json:"id"`
+ ProviderType string `json:"providerType"`
+ BaseURL string `json:"baseUrl"`
+ ProviderAccountID string `json:"providerAccountId,omitempty"`
+ Login string `json:"login,omitempty"`
+ DisplayName string `json:"displayName,omitempty"`
+ Scopes []string `json:"scopes"`
+ IsDefault bool `json:"isDefault"`
+ Status string `json:"status"`
+ CredentialVersion int64 `json:"credentialVersion"`
+ LastValidatedAt *time.Time `json:"lastValidatedAt,omitempty"`
+ LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
+ LastErrorCode string `json:"lastErrorCode,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type ProviderCredential struct {
+ ConnectionID uuid.UUID
+ UserID uuid.UUID
+ ProviderType string
+ BaseURL string
+ Token []byte
+ CredentialVersion int64
+}
+
+type ProviderProfile struct {
+ AccountID string
+ Login string
+ Name string
+ Scopes []string
+}
+
+type ProviderStore interface {
+ ListProviderConnections(context.Context, uuid.UUID, pagination.Request) (pagination.Page[ProviderConnection], error)
+ CreateProviderConnection(context.Context, uuid.UUID, string, string, []byte, []byte, int, ProviderProfile) (ProviderConnection, error)
+ GetProviderSecret(context.Context, uuid.UUID, uuid.UUID) (ProviderConnection, []byte, []byte, int, error)
+ MarkProviderConnectionUsed(context.Context, uuid.UUID, uuid.UUID) error
+ ReplaceProviderCredential(context.Context, uuid.UUID, uuid.UUID, []byte, []byte, int, ProviderProfile) (ProviderConnection, error)
+ SetProviderValidation(context.Context, uuid.UUID, uuid.UUID, ProviderProfile, string, string) (ProviderConnection, error)
+ SetDefaultProviderConnection(context.Context, uuid.UUID, uuid.UUID) (ProviderConnection, error)
+ RevokeProviderConnection(context.Context, uuid.UUID, uuid.UUID) (bool, error)
+}
+
+type ProviderValidator interface {
+ Validate(context.Context, string, string, string) (ProviderProfile, error)
+}
+
+type ProviderService struct {
+ store ProviderStore
+ cipher secret.Cipher
+ validator ProviderValidator
+}
+
+func NewProviderService(store ProviderStore, cipher secret.Cipher, validator ProviderValidator) *ProviderService {
+ return &ProviderService{store: store, cipher: cipher, validator: validator}
+}
+
+func (s *ProviderService) List(ctx context.Context, userID uuid.UUID, page pagination.Request) (pagination.Page[ProviderConnection], error) {
+ return s.store.ListProviderConnections(ctx, userID, page)
+}
+
+func (s *ProviderService) Get(ctx context.Context, userID, connectionID uuid.UUID) (ProviderConnection, error) {
+ connection, _, _, _, err := s.store.GetProviderSecret(ctx, userID, connectionID)
+
+ return connection, err
+}
+
+func (s *ProviderService) Create(ctx context.Context, userID uuid.UUID, providerType, baseURL, token string) (ProviderConnection, error) {
+ providerType, baseURL, token, err := normalizeProviderInput(providerType, baseURL, token)
+ if err != nil {
+ return ProviderConnection{}, err
+ }
+ profile, err := s.validator.Validate(ctx, providerType, baseURL, token)
+ if err != nil {
+ return ProviderConnection{}, fault.Wrap(fault.Invalid, "provider.credential_invalid", "Provider credential could not be validated", err)
+ }
+ ciphertext, nonce, keyVersion, err := s.cipher.Encrypt([]byte(token))
+ if err != nil {
+ return ProviderConnection{}, err
+ }
+
+ return s.store.CreateProviderConnection(ctx, userID, providerType, baseURL, ciphertext, nonce, keyVersion, profile)
+}
+
+func (s *ProviderService) ReplaceToken(ctx context.Context, userID, connectionID uuid.UUID, token string) (ProviderConnection, error) {
+ connection, _, _, _, err := s.store.GetProviderSecret(ctx, userID, connectionID)
+ if err != nil {
+ return ProviderConnection{}, err
+ }
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return ProviderConnection{}, fault.New(fault.Invalid, "provider.token_required", "Token is required")
+ }
+ profile, err := s.validator.Validate(ctx, connection.ProviderType, connection.BaseURL, token)
+ if err != nil {
+ return ProviderConnection{}, fault.Wrap(fault.Invalid, "provider.credential_invalid", "Provider credential could not be validated", err)
+ }
+ ciphertext, nonce, keyVersion, err := s.cipher.Encrypt([]byte(token))
+ if err != nil {
+ return ProviderConnection{}, err
+ }
+
+ return s.store.ReplaceProviderCredential(ctx, userID, connectionID, ciphertext, nonce, keyVersion, profile)
+}
+
+func (s *ProviderService) Test(ctx context.Context, userID, connectionID uuid.UUID) (ProviderConnection, error) {
+ connection, token, err := s.Credential(ctx, userID, connectionID, 0)
+ if err != nil {
+ return ProviderConnection{}, err
+ }
+ defer clear(token.Token)
+ profile, validationErr := s.validator.Validate(ctx, connection.ProviderType, connection.BaseURL, string(token.Token))
+ status, code := "active", ""
+ if validationErr != nil {
+ status, code = "invalid", "provider.authentication_failed"
+ }
+ updated, err := s.store.SetProviderValidation(ctx, userID, connectionID, profile, status, code)
+ if err != nil {
+ return ProviderConnection{}, err
+ }
+ if validationErr != nil {
+ return updated, fault.Wrap(fault.Invalid, "provider.credential_invalid", "Provider credential could not be validated", validationErr)
+ }
+
+ return updated, nil
+}
+
+func (s *ProviderService) Credential(ctx context.Context, userID, connectionID uuid.UUID, requiredVersion int64) (ProviderConnection, ProviderCredential, error) {
+ connection, ciphertext, nonce, keyVersion, err := s.store.GetProviderSecret(ctx, userID, connectionID)
+ if err != nil {
+ return ProviderConnection{}, ProviderCredential{}, err
+ }
+ if requiredVersion != 0 && connection.CredentialVersion != requiredVersion {
+ return ProviderConnection{}, ProviderCredential{}, fault.New(fault.Conflict, "provider.credential_stale", "Provider credential changed after the operation was requested")
+ }
+ plaintext, err := s.cipher.Decrypt(ciphertext, nonce, keyVersion)
+ if err != nil {
+ return ProviderConnection{}, ProviderCredential{}, err
+ }
+ if err := s.store.MarkProviderConnectionUsed(ctx, userID, connection.ID); err != nil {
+ clear(plaintext)
+ return ProviderConnection{}, ProviderCredential{}, err
+ }
+
+ return connection, ProviderCredential{ConnectionID: connection.ID, UserID: userID, ProviderType: connection.ProviderType, BaseURL: connection.BaseURL, Token: plaintext, CredentialVersion: connection.CredentialVersion}, nil
+}
+
+func (s *ProviderService) SetDefault(ctx context.Context, userID, connectionID uuid.UUID) (ProviderConnection, error) {
+ return s.store.SetDefaultProviderConnection(ctx, userID, connectionID)
+}
+
+func (s *ProviderService) Delete(ctx context.Context, userID, connectionID uuid.UUID) error {
+ ok, err := s.store.RevokeProviderConnection(ctx, userID, connectionID)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fault.New(fault.NotFound, "provider.connection_not_found", "Provider connection not found")
+ }
+
+ return nil
+}
+
+func normalizeProviderInput(providerType, baseURL, token string) (string, string, string, error) {
+ providerType = strings.ToLower(strings.TrimSpace(providerType))
+ if providerType != "github" && providerType != "gitlab" {
+ return "", "", "", fault.New(fault.Invalid, "provider.type_invalid", "Provider type must be github or gitlab")
+ }
+ switch providerType {
+ case "github":
+ baseURL = "https://github.com"
+ case "gitlab":
+ baseURL = "https://gitlab.com"
+ }
+ token = strings.TrimSpace(token)
+ if token == "" {
+ return "", "", "", fault.New(fault.Invalid, "provider.token_required", "Token is required")
+ }
+
+ return providerType, baseURL, token, nil
+}
+
+type HTTPProviderValidator struct{ client *http.Client }
+
+func NewHTTPProviderValidator(client *http.Client) *HTTPProviderValidator {
+ if client == nil {
+ client = &http.Client{Timeout: 15 * time.Second}
+ }
+ return &HTTPProviderValidator{client: client}
+}
+
+func (v *HTTPProviderValidator) Validate(ctx context.Context, providerType, baseURL, token string) (ProviderProfile, error) {
+ endpoint := strings.TrimRight(baseURL, "/") + "/api/v4/user"
+ if providerType == "github" {
+ if baseURL == "https://github.com" {
+ endpoint = "https://api.github.com/user"
+ } else {
+ endpoint = strings.TrimRight(baseURL, "/") + "/api/v3/user"
+ }
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return ProviderProfile{}, err
+ }
+ request.Header.Set("Accept", "application/json")
+ request.Header.Set("Authorization", "Bearer "+token)
+ request.Header.Set("User-Agent", "MoonCode")
+ response, err := v.client.Do(request)
+ if err != nil {
+ return ProviderProfile{}, err
+ }
+ defer func() { _ = response.Body.Close() }()
+ if response.StatusCode != http.StatusOK {
+ return ProviderProfile{}, fmt.Errorf("provider returned HTTP %d", response.StatusCode)
+ }
+ var body struct {
+ ID any `json:"id"`
+ Login string `json:"login"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+ }
+ if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
+ return ProviderProfile{}, err
+ }
+ login := body.Login
+ if login == "" {
+ login = body.Username
+ }
+ scopesHeader := response.Header.Get("X-OAuth-Scopes")
+ if providerType == "gitlab" {
+ scopesHeader = response.Header.Get("X-OAuth-Scopes")
+ }
+ var scopes []string
+ for _, scope := range strings.Split(scopesHeader, ",") {
+ if value := strings.TrimSpace(scope); value != "" {
+ scopes = append(scopes, value)
+ }
+ }
+
+ return ProviderProfile{AccountID: fmt.Sprint(body.ID), Login: login, Name: body.Name, Scopes: scopes}, nil
+}
diff --git a/internal/identity/biz/provider_test.go b/internal/identity/biz/provider_test.go
new file mode 100644
index 0000000..de9475b
--- /dev/null
+++ b/internal/identity/biz/provider_test.go
@@ -0,0 +1,30 @@
+package biz
+
+import "testing"
+
+func TestNormalizeProviderInputUsesOfficialHosts(t *testing.T) {
+ tests := []struct {
+ provider string
+ inputURL string
+ wantURL string
+ }{
+ {provider: "github", inputURL: "https://attacker.invalid", wantURL: "https://github.com"},
+ {provider: "gitlab", inputURL: "https://127.0.0.1", wantURL: "https://gitlab.com"},
+ }
+
+ for _, test := range tests {
+ provider, baseURL, token, err := normalizeProviderInput(test.provider, test.inputURL, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if provider != test.provider || baseURL != test.wantURL || token != "secret" {
+ t.Fatalf("unexpected normalized provider: %q %q %q", provider, baseURL, token)
+ }
+ }
+}
+
+func TestNormalizeProviderInputRejectsMissingToken(t *testing.T) {
+ if _, _, _, err := normalizeProviderInput("github", "", " "); err == nil {
+ t.Fatal("expected missing token to be rejected")
+ }
+}
diff --git a/internal/identity/biz/service.go b/internal/identity/biz/service.go
new file mode 100644
index 0000000..126511b
--- /dev/null
+++ b/internal/identity/biz/service.go
@@ -0,0 +1,228 @@
+package biz
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+)
+
+var ErrForbidden = fault.New(fault.Forbidden, "workspace.forbidden", "Workspace permission denied")
+
+type Option func(*Service)
+
+type Service struct {
+ store Store
+ issuer string
+ now func() time.Time
+ random func([]byte) (int, error)
+}
+
+func WithClock(now func() time.Time) Option {
+ return func(service *Service) { service.now = now }
+}
+
+func NewService(store Store, issuer string, options ...Option) *Service {
+ service := &Service{store: store, issuer: issuer, now: time.Now, random: rand.Read}
+ for _, option := range options {
+ option(service)
+ }
+
+ return service
+}
+
+func (s *Service) Resolve(ctx context.Context, identity auth.Identity) (auth.Actor, error) {
+ user, err := s.store.ResolveIdentity(ctx, s.issuer, identity)
+ if err != nil {
+ return auth.Actor{}, err
+ }
+
+ return auth.Actor{UserID: user.ID, Subject: identity.Subject, DisplayName: user.DisplayName, Username: user.Username, Email: user.Email, Status: user.Status}, nil
+}
+
+func (s *Service) Session(ctx context.Context, actor auth.Actor) (User, []Workspace, error) {
+ user, err := s.store.GetUser(ctx, actor.UserID)
+ if err != nil {
+ return User{}, nil, err
+ }
+ workspaces, err := s.store.ListWorkspaces(ctx, actor.UserID)
+ if err != nil {
+ return User{}, nil, err
+ }
+
+ return user, workspaces, nil
+}
+
+func (s *Service) CompleteOnboarding(ctx context.Context, actor auth.Actor) error {
+ if _, err := s.store.ActivateUser(ctx, actor.UserID); err != nil {
+ return err
+ }
+ workspaces, err := s.store.ListWorkspaces(ctx, actor.UserID)
+ if err != nil {
+ return err
+ }
+ if len(workspaces) > 0 {
+ return nil
+ }
+
+ name := actor.DisplayName
+ if name == "" {
+ name = actor.Username
+ }
+ if name == "" {
+ name = "My Workspace"
+ } else {
+ name += "'s Workspace"
+ }
+ _, err = s.store.CreateWorkspace(ctx, actor.UserID, name, uniqueSlug(name))
+
+ return err
+}
+
+func (s *Service) CreateWorkspace(ctx context.Context, actor auth.Actor, name string) (Workspace, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return Workspace{}, fault.New(fault.Invalid, "workspace.name_required", "Workspace name is required")
+ }
+
+ return s.store.CreateWorkspace(ctx, actor.UserID, name, uniqueSlug(name))
+}
+
+func (s *Service) Membership(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, minimum string) (Membership, error) {
+ membership, err := s.store.GetMembership(ctx, workspaceID, actor.UserID)
+ if err != nil {
+ return Membership{}, ErrForbidden
+ }
+ levels := map[string]int{"member": 1, "admin": 2, "owner": 3}
+ if levels[membership.Role] < levels[minimum] {
+ return Membership{}, ErrForbidden
+ }
+
+ return membership, nil
+}
+
+func (s *Service) UpdateWorkspace(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, update WorkspaceUpdate) (Workspace, error) {
+ membership, err := s.Membership(ctx, actor, workspaceID, "admin")
+ if err != nil {
+ return Workspace{}, err
+ }
+ if update.Name == nil && update.ReportRetentionDays == nil {
+ return Workspace{}, fault.New(fault.Invalid, "workspace.update_required", "At least one workspace setting is required")
+ }
+ if update.Name != nil {
+ name := strings.TrimSpace(*update.Name)
+ if name == "" {
+ return Workspace{}, fault.New(fault.Invalid, "workspace.name_required", "Workspace name is required")
+ }
+ update.Name = &name
+ }
+ if update.ReportRetentionDays != nil && (*update.ReportRetentionDays < 1 || *update.ReportRetentionDays > 3650) {
+ return Workspace{}, fault.New(fault.Invalid, "workspace.report_retention_invalid", "Report retention must be between 1 and 3650 days")
+ }
+
+ workspace, err := s.store.UpdateWorkspace(ctx, workspaceID, update)
+ workspace.Role = membership.Role
+
+ return workspace, err
+}
+
+func (s *Service) Members(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Member], error) {
+ if _, err := s.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return pagination.Page[Member]{}, err
+ }
+
+ return s.store.ListMembers(ctx, workspaceID, page)
+}
+
+func (s *Service) RemoveMember(ctx context.Context, actor auth.Actor, workspaceID, userID uuid.UUID) error {
+ if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return err
+ }
+ removed, err := s.store.RemoveMember(ctx, workspaceID, userID)
+ if err != nil {
+ return err
+ }
+ if !removed {
+ return fault.New(fault.Conflict, "workspace.member_not_removable", "Member cannot be removed")
+ }
+
+ return nil
+}
+
+func (s *Service) Invite(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, email string) (Invitation, error) {
+ if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return Invitation{}, err
+ }
+ email = strings.ToLower(strings.TrimSpace(email))
+ if !strings.Contains(email, "@") {
+ return Invitation{}, fault.New(fault.Invalid, "workspace.invitation_email_invalid", "A valid email is required")
+ }
+ raw := make([]byte, 32)
+ if _, err := s.random(raw); err != nil {
+ return Invitation{}, fmt.Errorf("create invitation token: %w", err)
+ }
+ token := base64.RawURLEncoding.EncodeToString(raw)
+ hash := sha256.Sum256([]byte(token))
+
+ invitation, err := s.store.CreateInvitation(ctx, workspaceID, actor.UserID, email, hash[:], s.now().Add(7*24*time.Hour))
+ if err != nil {
+ return Invitation{}, err
+ }
+ invitation.Token = token
+
+ return invitation, nil
+}
+
+func (s *Service) Invitations(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Invitation], error) {
+ if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return pagination.Page[Invitation]{}, err
+ }
+
+ return s.store.ListInvitations(ctx, workspaceID, page)
+}
+
+func (s *Service) RevokeInvitation(ctx context.Context, actor auth.Actor, workspaceID, invitationID uuid.UUID) error {
+ if _, err := s.Membership(ctx, actor, workspaceID, "admin"); err != nil {
+ return err
+ }
+ ok, err := s.store.RevokeInvitation(ctx, workspaceID, invitationID)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fault.New(fault.NotFound, "workspace.invitation_not_found", "Invitation not found")
+ }
+
+ return nil
+}
+
+func (s *Service) AcceptInvitation(ctx context.Context, actor auth.Actor, token string) error {
+ hash := sha256.Sum256([]byte(token))
+ _, err := s.store.AcceptInvitation(ctx, actor.UserID, hash[:], actor.Email)
+ if err != nil {
+ return err
+ }
+ _, err = s.store.ActivateUser(ctx, actor.UserID)
+
+ return err
+}
+
+var slugCleaner = regexp.MustCompile(`[^a-z0-9]+`)
+
+func uniqueSlug(name string) string {
+ base := strings.Trim(slugCleaner.ReplaceAllString(strings.ToLower(name), "-"), "-")
+ if base == "" {
+ base = "workspace"
+ }
+
+ return fmt.Sprintf("%s-%s", base, strings.ToLower(uuid.NewString()[:8]))
+}
diff --git a/internal/identity/biz/store.go b/internal/identity/biz/store.go
new file mode 100644
index 0000000..40d53c7
--- /dev/null
+++ b/internal/identity/biz/store.go
@@ -0,0 +1,26 @@
+package biz
+
+import (
+ "context"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+)
+
+type Store interface {
+ ResolveIdentity(context.Context, string, auth.Identity) (User, error)
+ ActivateUser(context.Context, uuid.UUID) (User, error)
+ GetUser(context.Context, uuid.UUID) (User, error)
+ ListWorkspaces(context.Context, uuid.UUID) ([]Workspace, error)
+ CreateWorkspace(context.Context, uuid.UUID, string, string) (Workspace, error)
+ GetMembership(context.Context, uuid.UUID, uuid.UUID) (Membership, error)
+ UpdateWorkspace(context.Context, uuid.UUID, WorkspaceUpdate) (Workspace, error)
+ ListMembers(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Member], error)
+ RemoveMember(context.Context, uuid.UUID, uuid.UUID) (bool, error)
+ CreateInvitation(context.Context, uuid.UUID, uuid.UUID, string, []byte, time.Time) (Invitation, error)
+ ListInvitations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Invitation], error)
+ RevokeInvitation(context.Context, uuid.UUID, uuid.UUID) (bool, error)
+ AcceptInvitation(context.Context, uuid.UUID, []byte, string) (Workspace, error)
+}
diff --git a/internal/identity/context.go b/internal/identity/context.go
deleted file mode 100644
index f50a383..0000000
--- a/internal/identity/context.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package identity
-
-import (
- "context"
-
- "github.com/mooncode-ai/mooncode/internal/model"
-)
-
-type principalKey struct{}
-
-func WithPrincipal(ctx context.Context, principal model.Principal) context.Context {
- return context.WithValue(ctx, principalKey{}, principal)
-}
-
-func PrincipalFromContext(ctx context.Context) (model.Principal, bool) {
- principal, ok := ctx.Value(principalKey{}).(model.Principal)
- return principal, ok
-}
diff --git a/internal/identity/data/membership_integration_test.go b/internal/identity/data/membership_integration_test.go
new file mode 100644
index 0000000..8ed0aa1
--- /dev/null
+++ b/internal/identity/data/membership_integration_test.go
@@ -0,0 +1,56 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+func TestMembershipRequiresActiveUser(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID := uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Membership',$2,$3)`, workspaceID, "membership-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspace_members (workspace_id,user_id,role) VALUES ($1,$2,'member')`, workspaceID, userID); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ membership, err := store.GetMembership(ctx, workspaceID, userID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if membership.Role != "member" || membership.Workspace.Role != "member" {
+ t.Fatalf("membership roles = %q/%q, want member/member", membership.Role, membership.Workspace.Role)
+ }
+ workspaces, err := store.ListWorkspaces(ctx, userID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(workspaces) != 1 || workspaces[0].Role != "member" {
+ t.Fatalf("workspaces = %+v, want one member workspace", workspaces)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE users SET status='suspended' WHERE id=$1`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.GetMembership(ctx, workspaceID, userID); !errors.Is(err, pgx.ErrNoRows) {
+ t.Fatalf("suspended membership error = %v, want pgx.ErrNoRows", err)
+ }
+}
diff --git a/internal/identity/data/provider_integration_test.go b/internal/identity/data/provider_integration_test.go
new file mode 100644
index 0000000..db7a79d
--- /dev/null
+++ b/internal/identity/data/provider_integration_test.go
@@ -0,0 +1,151 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/fuchencong/mooncode/internal/platform/secret"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type fixedValidator struct{}
+
+func (fixedValidator) Validate(context.Context, string, string, string) (identity.ProviderProfile, error) {
+ return identity.ProviderProfile{AccountID: "1", Login: "octocat", Name: "Octocat", Scopes: []string{"repo"}}, nil
+}
+
+func TestProviderConnectionIsPersonalAndEncrypted(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+ store := NewStore(pool)
+ cipher, err := secret.NewAESGCM([]byte("01234567890123456789012345678901"), 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ service := identity.NewProviderService(store, cipher, fixedValidator{})
+ first, err := store.ResolveIdentity(ctx, "integration", auth.Identity{Subject: uuid.NewString(), Username: "first"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := store.ResolveIdentity(ctx, "integration", auth.Identity{Subject: uuid.NewString(), Username: "second"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := "github_pat_integration_secret"
+ connection, err := service.Create(ctx, first.ID, "github", "https://github.com", token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := service.Get(ctx, second.ID, connection.ID); !errors.Is(err, pgx.ErrNoRows) {
+ t.Fatalf("second user unexpectedly accessed connection: %v", err)
+ }
+ if _, err := service.Get(ctx, first.ID, connection.ID); err != nil {
+ t.Fatal(err)
+ }
+ var usedBeforeCredential bool
+ if err := pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM provider_connections WHERE id=$1`, connection.ID).Scan(&usedBeforeCredential); err != nil {
+ t.Fatal(err)
+ }
+ if usedBeforeCredential {
+ t.Fatal("local provider connection lookup marked the PAT as used")
+ }
+ _, credential, err := service.Credential(ctx, first.ID, connection.ID, connection.CredentialVersion)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(credential.Token) != token {
+ t.Fatal("decrypted provider credential did not match the stored PAT")
+ }
+ clear(credential.Token)
+ var usedAfterCredential bool
+ if err := pool.QueryRow(ctx, `SELECT last_used_at IS NOT NULL FROM provider_connections WHERE id=$1`, connection.ID).Scan(&usedAfterCredential); err != nil {
+ t.Fatal(err)
+ }
+ if !usedAfterCredential {
+ t.Fatal("remote credential use did not update last_used_at")
+ }
+ replacementToken := "github_pat_integration_replacement_secret"
+ connection, err = service.ReplaceToken(ctx, first.ID, connection.ID, replacementToken)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := service.SetDefault(ctx, first.ID, connection.ID); err != nil {
+ t.Fatal(err)
+ }
+ var ciphertext []byte
+ if err := pool.QueryRow(ctx, "SELECT token_ciphertext FROM provider_connections WHERE id=$1", connection.ID).Scan(&ciphertext); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(ciphertext), token) || strings.Contains(string(ciphertext), replacementToken) {
+ t.Fatal("PAT was stored in plaintext")
+ }
+ items, err := service.List(ctx, first.ID, pagination.Request{Limit: 50})
+ if err != nil || len(items.Items) != 1 {
+ t.Fatalf("list: %v %#v", err, items)
+ }
+ if err := service.Delete(ctx, first.ID, connection.ID); err != nil {
+ t.Fatal(err)
+ }
+ assertProviderAudit(t, ctx, pool, first.ID, connection.ID, token, replacementToken)
+ if _, err := service.Create(ctx, first.ID, "github", "https://github.com", token); err != nil {
+ t.Fatalf("recreate revoked connection: %v", err)
+ }
+}
+
+func assertProviderAudit(t *testing.T, ctx context.Context, pool interface {
+ Query(context.Context, string, ...any) (pgx.Rows, error)
+}, actorID, connectionID uuid.UUID, secrets ...string) {
+ t.Helper()
+
+ wantActions := map[string]int{
+ audit.ActionProviderConnectionCreated: 1,
+ audit.ActionProviderCredentialRotated: 1,
+ audit.ActionProviderConnectionDefaultSet: 1,
+ audit.ActionProviderConnectionRevoked: 1,
+ }
+ rows, err := pool.Query(ctx, `SELECT workspace_id,actor_user_id,action,metadata::text FROM audit_events WHERE resource_type=$1 AND resource_id=$2`, audit.ResourceProviderConnection, connectionID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ seen := make(map[string]int)
+ for rows.Next() {
+ var workspaceID, eventActorID uuid.NullUUID
+ var action, metadata string
+ if err := rows.Scan(&workspaceID, &eventActorID, &action, &metadata); err != nil {
+ t.Fatal(err)
+ }
+ if workspaceID.Valid || !eventActorID.Valid || eventActorID.UUID != actorID {
+ t.Fatalf("provider audit identity = (%+v, %+v), want (NULL, %s)", workspaceID, eventActorID, actorID)
+ }
+ for _, secret := range secrets {
+ if strings.Contains(metadata, secret) {
+ t.Fatalf("provider audit metadata leaked PAT: %s", metadata)
+ }
+ }
+ seen[action]++
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ for action, count := range wantActions {
+ if seen[action] != count {
+ t.Fatalf("audit action %q count = %d, want %d; all=%v", action, seen[action], count, seen)
+ }
+ }
+}
diff --git a/internal/identity/data/provider_store.go b/internal/identity/data/provider_store.go
new file mode 100644
index 0000000..b0e1e85
--- /dev/null
+++ b/internal/identity/data/provider_store.go
@@ -0,0 +1,210 @@
+package data
+
+import (
+ "context"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/data/pagecursor"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+)
+
+func (s *Store) ListProviderConnections(ctx context.Context, userID uuid.UUID, page pagination.Request) (pagination.Page[identity.ProviderConnection], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListProviderConnections(ctx, sqlc.ListProviderConnectionsParams{
+ UserID: userID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[identity.ProviderConnection]{}, err
+ }
+ items := make([]identity.ProviderConnection, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, identity.ProviderConnection{
+ ID: row.ID, ProviderType: row.ProviderType, BaseURL: row.BaseUrl,
+ ProviderAccountID: row.ProviderAccountID.String, Login: row.Login.String, DisplayName: row.DisplayName.String,
+ Scopes: row.Scopes, IsDefault: row.IsDefault, Status: row.Status, CredentialVersion: row.CredentialVersion,
+ LastValidatedAt: optionalTime(row.LastValidatedAt), LastUsedAt: optionalTime(row.LastUsedAt), LastErrorCode: row.LastErrorCode.String,
+ CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
+ })
+ }
+
+ return pagination.Build(items, page.Limit, func(item identity.ProviderConnection) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) CreateProviderConnection(ctx context.Context, userID uuid.UUID, providerType, baseURL string, ciphertext, nonce []byte, keyVersion int, profile identity.ProviderProfile) (identity.ProviderConnection, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ var count int
+ if err := tx.QueryRow(ctx, `SELECT count(*) FROM provider_connections WHERE user_id=$1 AND provider_type=$2 AND status <> 'revoked'`, userID, providerType).Scan(&count); err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ row, err := q.CreateProviderConnection(ctx, sqlc.CreateProviderConnectionParams{
+ ID: uuid.New(), UserID: userID, ProviderType: providerType, BaseUrl: baseURL,
+ TokenCiphertext: ciphertext, TokenNonce: nonce, KeyVersion: int32(keyVersion),
+ ProviderAccountID: text(profile.AccountID), Login: text(profile.Login), DisplayName: text(profile.Name), Scopes: profile.Scopes, IsDefault: count == 0,
+ })
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ ActorUserID: userID,
+ Action: audit.ActionProviderConnectionCreated,
+ Resource: audit.ResourceProviderConnection,
+ ResourceID: row.ID,
+ Metadata: map[string]any{
+ "providerType": row.ProviderType,
+ "isDefault": row.IsDefault,
+ },
+ }); err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ err = tx.Commit(ctx)
+
+ return mapProvider(row), err
+}
+
+func (s *Store) GetProviderSecret(ctx context.Context, userID, connectionID uuid.UUID) (identity.ProviderConnection, []byte, []byte, int, error) {
+ row, err := s.queries.GetProviderConnection(ctx, sqlc.GetProviderConnectionParams{ID: connectionID, UserID: userID})
+ return mapProvider(row), row.TokenCiphertext, row.TokenNonce, int(row.KeyVersion), err
+}
+
+func (s *Store) MarkProviderConnectionUsed(ctx context.Context, userID, connectionID uuid.UUID) error {
+ count, err := s.queries.MarkProviderConnectionUsed(ctx, sqlc.MarkProviderConnectionUsedParams{ID: connectionID, UserID: userID})
+ if err == nil && count == 0 {
+ return fault.New(fault.Conflict, "provider.connection_unavailable", "Provider connection is no longer available")
+ }
+
+ return err
+}
+
+func (s *Store) ReplaceProviderCredential(ctx context.Context, userID, connectionID uuid.UUID, ciphertext, nonce []byte, keyVersion int, profile identity.ProviderProfile) (identity.ProviderConnection, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ _, err = q.ReplaceProviderCredential(ctx, sqlc.ReplaceProviderCredentialParams{ID: connectionID, UserID: userID, TokenCiphertext: ciphertext, TokenNonce: nonce, KeyVersion: int32(keyVersion)})
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ row, err := q.SetProviderConnectionValidation(ctx, validationParams(connectionID, userID, profile, "active", ""))
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ ActorUserID: userID,
+ Action: audit.ActionProviderCredentialRotated,
+ Resource: audit.ResourceProviderConnection,
+ ResourceID: connectionID,
+ Metadata: map[string]any{
+ "providerType": row.ProviderType,
+ "credentialVersion": row.CredentialVersion,
+ },
+ }); err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ err = tx.Commit(ctx)
+
+ return mapProvider(row), err
+}
+
+func (s *Store) SetProviderValidation(ctx context.Context, userID, connectionID uuid.UUID, profile identity.ProviderProfile, status, code string) (identity.ProviderConnection, error) {
+ row, err := s.queries.SetProviderConnectionValidation(ctx, validationParams(connectionID, userID, profile, status, code))
+ return mapProvider(row), err
+}
+
+func (s *Store) SetDefaultProviderConnection(ctx context.Context, userID, connectionID uuid.UUID) (identity.ProviderConnection, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ connection, err := q.GetProviderConnection(ctx, sqlc.GetProviderConnectionParams{ID: connectionID, UserID: userID})
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ if err = q.ClearDefaultProviderConnections(ctx, sqlc.ClearDefaultProviderConnectionsParams{UserID: userID, ProviderType: connection.ProviderType}); err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ connection, err = q.SetDefaultProviderConnection(ctx, sqlc.SetDefaultProviderConnectionParams{ID: connectionID, UserID: userID})
+ if err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ ActorUserID: userID,
+ Action: audit.ActionProviderConnectionDefaultSet,
+ Resource: audit.ResourceProviderConnection,
+ ResourceID: connectionID,
+ Metadata: map[string]any{"providerType": connection.ProviderType},
+ }); err != nil {
+ return identity.ProviderConnection{}, err
+ }
+ err = tx.Commit(ctx)
+
+ return mapProvider(connection), err
+}
+
+func (s *Store) RevokeProviderConnection(ctx context.Context, userID, connectionID uuid.UUID) (bool, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ count, err := q.RevokeProviderConnection(ctx, sqlc.RevokeProviderConnectionParams{ID: connectionID, UserID: userID})
+ if err != nil || count == 0 {
+ return false, err
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ ActorUserID: userID,
+ Action: audit.ActionProviderConnectionRevoked,
+ Resource: audit.ResourceProviderConnection,
+ ResourceID: connectionID,
+ }); err != nil {
+ return false, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return false, err
+ }
+
+ return true, nil
+}
+
+func validationParams(id, userID uuid.UUID, profile identity.ProviderProfile, status, code string) sqlc.SetProviderConnectionValidationParams {
+ return sqlc.SetProviderConnectionValidationParams{ID: id, UserID: userID, ProviderAccountID: text(profile.AccountID), Login: text(profile.Login), DisplayName: text(profile.Name), Scopes: profile.Scopes, Status: status, LastErrorCode: text(code)}
+}
+
+func mapProvider(row sqlc.ProviderConnection) identity.ProviderConnection {
+ return identity.ProviderConnection{
+ ID: row.ID, ProviderType: row.ProviderType, BaseURL: row.BaseUrl,
+ ProviderAccountID: row.ProviderAccountID.String, Login: row.Login.String, DisplayName: row.DisplayName.String,
+ Scopes: row.Scopes, IsDefault: row.IsDefault, Status: row.Status, CredentialVersion: row.CredentialVersion,
+ LastValidatedAt: optionalTime(row.LastValidatedAt), LastUsedAt: optionalTime(row.LastUsedAt), LastErrorCode: row.LastErrorCode.String,
+ CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
+ }
+}
+
+func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} }
+func optionalTime(value pgtype.Timestamptz) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Time
+ return &result
+}
diff --git a/internal/identity/data/store.go b/internal/identity/data/store.go
new file mode 100644
index 0000000..2f0da42
--- /dev/null
+++ b/internal/identity/data/store.go
@@ -0,0 +1,251 @@
+package data
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/data/pagecursor"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ pool *pgxpool.Pool
+ queries *sqlc.Queries
+}
+
+func NewStore(pool *pgxpool.Pool) *Store {
+ return &Store{pool: pool, queries: sqlc.New(pool)}
+}
+
+func (s *Store) ResolveIdentity(ctx context.Context, issuer string, source auth.Identity) (identity.User, error) {
+ params := sqlc.GetUserByIdentityParams{Issuer: issuer, Subject: source.Subject}
+ user, err := s.queries.GetUserByIdentity(ctx, params)
+ if err == nil {
+ user, err = s.queries.UpdateUserProfile(ctx, sqlc.UpdateUserProfileParams{ID: user.ID, DisplayName: source.DisplayName, Username: source.Username, Email: source.Email})
+ return mapUser(user), err
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return identity.User{}, err
+ }
+
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return identity.User{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ user, err = q.CreateUser(ctx, sqlc.CreateUserParams{ID: uuid.New(), DisplayName: source.DisplayName, Username: source.Username, Email: source.Email, Status: "pending"})
+ if err == nil {
+ err = q.CreateOAuthIdentity(ctx, sqlc.CreateOAuthIdentityParams{Issuer: issuer, Subject: source.Subject, UserID: user.ID})
+ }
+ if err == nil {
+ err = tx.Commit(ctx)
+ }
+ if err != nil {
+ if existing, lookupErr := s.queries.GetUserByIdentity(ctx, params); lookupErr == nil {
+ return mapUser(existing), nil
+ }
+ return identity.User{}, fmt.Errorf("create identity: %w", err)
+ }
+
+ return mapUser(user), nil
+}
+
+func (s *Store) ActivateUser(ctx context.Context, id uuid.UUID) (identity.User, error) {
+ user, err := s.queries.ActivateUser(ctx, id)
+ if errors.Is(err, pgx.ErrNoRows) {
+ user, err = s.queries.GetUser(ctx, id)
+ }
+
+ return mapUser(user), err
+}
+
+func (s *Store) GetUser(ctx context.Context, id uuid.UUID) (identity.User, error) {
+ user, err := s.queries.GetUser(ctx, id)
+ return mapUser(user), err
+}
+
+func (s *Store) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]identity.Workspace, error) {
+ rows, err := s.queries.ListUserWorkspaces(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ items := make([]identity.Workspace, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, identity.Workspace{
+ ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays,
+ CreatedBy: row.CreatedBy, Role: row.Role,
+ })
+ }
+
+ return items, nil
+}
+
+func (s *Store) CreateWorkspace(ctx context.Context, userID uuid.UUID, name, slug string) (identity.Workspace, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return identity.Workspace{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ workspace, err := q.CreateWorkspace(ctx, sqlc.CreateWorkspaceParams{ID: uuid.New(), Name: name, Slug: slug, CreatedBy: userID})
+ if err == nil {
+ err = q.AddWorkspaceMember(ctx, sqlc.AddWorkspaceMemberParams{WorkspaceID: workspace.ID, UserID: userID, Role: "owner"})
+ }
+ if err == nil {
+ err = q.CreateRetentionCleanupSchedule(ctx, workspace.ID)
+ }
+ if err == nil {
+ profileID := uuid.New()
+ _, err = q.CreateAnalysisProfile(ctx, sqlc.CreateAnalysisProfileParams{
+ ID: profileID, WorkspaceID: workspace.ID, Name: "Code scale", CreatedBy: userID,
+ })
+ if err == nil {
+ _, err = q.CreateAnalysisProfileVersion(ctx, sqlc.CreateAnalysisProfileVersionParams{
+ ID: uuid.New(), WorkspaceID: workspace.ID, ProfileID: profileID, Version: 1,
+ DimensionKey: "code_scale", Definition: analysis.DefaultProfileDefinition(), CreatedBy: userID,
+ })
+ }
+ }
+ if err == nil {
+ err = tx.Commit(ctx)
+ }
+
+ item := mapWorkspace(workspace)
+ item.Role = "owner"
+
+ return item, err
+}
+
+func (s *Store) GetMembership(ctx context.Context, workspaceID, userID uuid.UUID) (identity.Membership, error) {
+ row, err := s.queries.GetWorkspaceMembership(ctx, sqlc.GetWorkspaceMembershipParams{ID: workspaceID, UserID: userID})
+ return identity.Membership{Workspace: identity.Workspace{ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, CreatedBy: row.CreatedBy, Role: row.Role}, Role: row.Role}, err
+}
+
+func (s *Store) UpdateWorkspace(ctx context.Context, id uuid.UUID, update identity.WorkspaceUpdate) (identity.Workspace, error) {
+ params := sqlc.UpdateWorkspaceParams{ID: id}
+ if update.Name != nil {
+ params.Name = pgtype.Text{String: *update.Name, Valid: true}
+ }
+ if update.ReportRetentionDays != nil {
+ params.ReportRetentionDays = pgtype.Int4{Int32: *update.ReportRetentionDays, Valid: true}
+ }
+ row, err := s.queries.UpdateWorkspace(ctx, params)
+ return mapWorkspace(row), err
+}
+
+func (s *Store) ListMembers(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[identity.Member], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListWorkspaceMembers(ctx, sqlc.ListWorkspaceMembersParams{
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[identity.Member]{}, err
+ }
+ items := make([]identity.Member, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, identity.Member{UserID: row.ID, Username: row.Username, Email: row.Email, DisplayName: row.DisplayName, Role: row.Role, CreatedAt: row.CreatedAt.Time})
+ }
+
+ return pagination.Build(items, page.Limit, func(item identity.Member) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.UserID
+ }), nil
+}
+
+func (s *Store) RemoveMember(ctx context.Context, workspaceID, userID uuid.UUID) (bool, error) {
+ count, err := s.queries.RemoveWorkspaceMember(ctx, sqlc.RemoveWorkspaceMemberParams{WorkspaceID: workspaceID, UserID: userID})
+ return count > 0, err
+}
+
+func (s *Store) CreateInvitation(ctx context.Context, workspaceID, actorID uuid.UUID, email string, hash []byte, expiresAt time.Time) (identity.Invitation, error) {
+ row, err := s.queries.CreateWorkspaceInvitation(ctx, sqlc.CreateWorkspaceInvitationParams{ID: uuid.New(), WorkspaceID: workspaceID, Email: email, TokenHash: hash, CreatedBy: actorID, ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true}})
+ return mapInvitation(row), err
+}
+
+func (s *Store) ListInvitations(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[identity.Invitation], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListWorkspaceInvitations(ctx, sqlc.ListWorkspaceInvitationsParams{
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[identity.Invitation]{}, err
+ }
+ items := make([]identity.Invitation, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, mapInvitation(row))
+ }
+
+ return pagination.Build(items, page.Limit, func(item identity.Invitation) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) RevokeInvitation(ctx context.Context, workspaceID, invitationID uuid.UUID) (bool, error) {
+ count, err := s.queries.RevokeWorkspaceInvitation(ctx, sqlc.RevokeWorkspaceInvitationParams{ID: invitationID, WorkspaceID: workspaceID})
+ return count > 0, err
+}
+
+func (s *Store) AcceptInvitation(ctx context.Context, userID uuid.UUID, hash []byte, email string) (identity.Workspace, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return identity.Workspace{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ invitation, err := q.GetInvitationByTokenHash(ctx, hash)
+ if err != nil {
+ return identity.Workspace{}, err
+ }
+ if !strings.EqualFold(invitation.Email, email) {
+ return identity.Workspace{}, fault.New(fault.Forbidden, "workspace.invitation_recipient_mismatch", "Invitation does not belong to the authenticated user")
+ }
+ if err = q.AddWorkspaceMember(ctx, sqlc.AddWorkspaceMemberParams{WorkspaceID: invitation.WorkspaceID, UserID: userID, Role: "member"}); err != nil {
+ return identity.Workspace{}, err
+ }
+ if count, acceptErr := q.AcceptWorkspaceInvitation(ctx, invitation.ID); acceptErr != nil || count != 1 {
+ return identity.Workspace{}, fault.New(fault.Invalid, "workspace.invitation_unavailable", "Invitation is no longer available")
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return identity.Workspace{}, err
+ }
+ membership, err := s.GetMembership(ctx, invitation.WorkspaceID, userID)
+
+ return membership.Workspace, err
+}
+
+func mapUser(row sqlc.User) identity.User {
+ return identity.User{ID: row.ID, DisplayName: row.DisplayName, Username: row.Username, Email: row.Email, Status: row.Status}
+}
+func mapWorkspace(row sqlc.Workspace) identity.Workspace {
+ return identity.Workspace{ID: row.ID, Name: row.Name, Slug: row.Slug, ReportRetentionDays: row.ReportRetentionDays, CreatedBy: row.CreatedBy}
+}
+func mapInvitation(row sqlc.WorkspaceInvitation) identity.Invitation {
+ item := identity.Invitation{ID: row.ID, WorkspaceID: row.WorkspaceID, Email: row.Email, ExpiresAt: row.ExpiresAt.Time, CreatedAt: row.CreatedAt.Time}
+ if row.AcceptedAt.Valid {
+ value := row.AcceptedAt.Time
+ item.AcceptedAt = &value
+ }
+ if row.RevokedAt.Valid {
+ value := row.RevokedAt.Time
+ item.RevokedAt = &value
+ }
+ return item
+}
diff --git a/internal/identity/headers.go b/internal/identity/headers.go
deleted file mode 100644
index 0dd3aca..0000000
--- a/internal/identity/headers.go
+++ /dev/null
@@ -1,66 +0,0 @@
-package identity
-
-import (
- "errors"
- "net/http"
- "strings"
- "unicode"
- "unicode/utf8"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
-)
-
-const GatewayTokenHeader = "X-MoonCode-Gateway-Token"
-
-var (
- ErrInvalidHeaders = errors.New("identity: invalid ForwardAuth headers")
- ErrSubjectRequired = errors.New("identity: OAuth subject is required")
-)
-
-func ParseHeaders(header http.Header, cfg config.IdentityConfig) (model.ExternalIdentity, error) {
- username, err := cleanHeader(header.Get("Remote-User"), true)
- if err != nil {
- return model.ExternalIdentity{}, err
- }
- displayName, err := cleanHeader(header.Get("Remote-Name"), false)
- if err != nil {
- return model.ExternalIdentity{}, err
- }
- email, err := cleanHeader(header.Get("Remote-Email"), false)
- if err != nil {
- return model.ExternalIdentity{}, err
- }
- subValue := header.Get("Remote-Sub")
- if strings.TrimSpace(subValue) == "" {
- return model.ExternalIdentity{}, ErrSubjectRequired
- }
- sub, err := cleanHeader(subValue, true)
- if err != nil {
- return model.ExternalIdentity{}, err
- }
- if len(sub) > 255 || strings.IndexFunc(sub, unicode.IsControl) >= 0 {
- return model.ExternalIdentity{}, ErrInvalidHeaders
- }
- if displayName == "" {
- displayName = username
- }
- return model.ExternalIdentity{
- Issuer: cfg.Issuer,
- Subject: sub,
- Username: username,
- Email: email,
- DisplayName: displayName,
- }, nil
-}
-
-func cleanHeader(value string, required bool) (string, error) {
- value = strings.TrimSpace(value)
- if required && value == "" {
- return "", ErrInvalidHeaders
- }
- if len(value) > 512 || !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\x00") {
- return "", ErrInvalidHeaders
- }
- return value, nil
-}
diff --git a/internal/identity/headers_test.go b/internal/identity/headers_test.go
deleted file mode 100644
index c7da5d0..0000000
--- a/internal/identity/headers_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package identity
-
-import (
- "net/http"
- "testing"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/stretchr/testify/require"
-)
-
-func TestParseHeadersUsesOAuthSubject(t *testing.T) {
- header := make(http.Header)
- header.Set("Remote-User", "Moon")
- header.Set("Remote-Name", "Moon User")
- header.Set("Remote-Email", "moon@example.com")
- header.Set("Remote-Sub", "subject-42")
-
- identity, err := ParseHeaders(header, config.IdentityConfig{Issuer: "https://github.com"})
- require.NoError(t, err)
- require.Equal(t, "subject-42", identity.Subject)
- require.Equal(t, "Moon", identity.Username)
-}
-
-func TestParseHeadersRequiresOAuthSubject(t *testing.T) {
- header := make(http.Header)
- header.Set("Remote-User", "Moon")
-
- _, err := ParseHeaders(header, config.IdentityConfig{Issuer: "https://github.com"})
- require.ErrorIs(t, err, ErrSubjectRequired)
-}
-
-func TestParseHeadersRejectsInjectedNewline(t *testing.T) {
- header := make(http.Header)
- header["Remote-User"] = []string{"moon\nadmin"}
-
- _, err := ParseHeaders(header, config.IdentityConfig{Issuer: "https://github.com"})
- require.ErrorIs(t, err, ErrInvalidHeaders)
-}
diff --git a/internal/jobs/git_sync.go b/internal/jobs/git_sync.go
deleted file mode 100644
index fcb68ca..0000000
--- a/internal/jobs/git_sync.go
+++ /dev/null
@@ -1,118 +0,0 @@
-package jobs
-
-import (
- "context"
- "encoding/json"
- "errors"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/checkout"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
- gitcore "github.com/mooncode-ai/mooncode/pkg/git"
- "github.com/mooncode-ai/mooncode/pkg/scm"
-)
-
-type GitSyncHandler struct {
- metadata repository.MetadataStore
- secrets secretstore.SecretStore
- registry *scm.Registry
- git gitcore.Client
- checkouts checkout.Store
- cfg config.Config
-}
-
-type GitSyncOption func(*GitSyncHandler)
-
-func NewGitSyncHandler(metadata repository.MetadataStore, secrets secretstore.SecretStore, registry *scm.Registry, client gitcore.Client, checkouts checkout.Store, cfg config.Config, opts ...GitSyncOption) *GitSyncHandler {
- handler := &GitSyncHandler{metadata: metadata, secrets: secrets, registry: registry, git: client, checkouts: checkouts, cfg: cfg}
- for _, option := range opts {
- if option != nil {
- option(handler)
- }
- }
- return handler
-}
-func (*GitSyncHandler) Type() string { return "repository.sync" }
-
-func (h *GitSyncHandler) Handle(ctx context.Context, job model.Job) (resultErr error) {
- var payload model.RepositorySyncPayload
- if err := json.Unmarshal(job.Payload, &payload); err != nil || payload.RepositoryID == uuid.Nil {
- return Permanent("repository.invalid_job", errors.New("invalid repository sync payload"))
- }
- repo, err := h.metadata.GetRepository(ctx, job.WorkspaceID, payload.RepositoryID)
- if err != nil {
- return Permanent("repository.not_found", err)
- }
- if err := h.metadata.MarkRepositorySyncing(ctx, job, repo.ID); err != nil {
- return Retryable("repository.state_failed", err)
- }
- defer func() {
- if resultErr != nil {
- _ = h.metadata.MarkSyncFailed(context.Background(), job, repo.ID, errorCode(resultErr), safeErrorMessage(resultErr))
- }
- }()
-
- providerType := "generic"
- credential := gitcore.Credential{}
- if repo.ConnectionID != nil {
- connection, err := h.metadata.GetSCMConnection(ctx, job.WorkspaceID, *repo.ConnectionID)
- if err != nil {
- return Permanent("repository.connection_missing", err)
- }
- providerType = connection.Type
- if connection.SecretRef != nil {
- values, err := h.secrets.Get(ctx, secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: job.WorkspaceID, ResourceType: "scm_connection", ResourceID: connection.ID}})
- if err != nil {
- return Permanent("repository.credential_failed", err)
- }
- credential = gitcore.NewCredential(reveal(values, "username"), reveal(values, "token"))
- }
- }
- provider, err := h.registry.Provider(providerType)
- if err != nil {
- return Permanent("repository.provider_unknown", err)
- }
- remote, err := provider.Validate(ctx, repo.CloneURL)
- if err != nil {
- return Permanent("repository.remote_rejected", err)
- }
-
- var fetched gitcore.FetchResult
- _, err = h.checkouts.Replace(ctx, repo.ID, func(ctx context.Context, directory string) error {
- var fetchErr error
- fetched, fetchErr = h.git.Fetch(ctx, gitcore.FetchRequest{
- Provider: providerType, Remote: remote.URL, Ref: repo.Ref, Directory: directory,
- Credential: credential, Depth: h.cfg.Git.Depth, Timeout: h.cfg.Git.CloneTimeout,
- })
- if fetchErr != nil {
- return fetchErr
- }
- _, fetchErr = h.metadata.GetRepository(ctx, job.WorkspaceID, repo.ID)
- return fetchErr
- })
- if err != nil {
- return Retryable("repository.fetch_failed", err)
- }
- if err := h.metadata.MarkSyncReady(ctx, job, repo.ID, fetched.CommitSHA); err != nil {
- return Retryable("repository.commit_failed", err)
- }
- return nil
-}
-func reveal(values secretstore.SecretValues, key string) string {
- if value, ok := values[key]; ok {
- return value.Reveal()
- }
- return ""
-}
-func errorCode(err error) string {
- var classified *Error
- if errors.As(err, &classified) {
- return classified.Code
- }
- return "repository.sync_failed"
-}
-
-var _ Handler = (*GitSyncHandler)(nil)
diff --git a/internal/jobs/runner.go b/internal/jobs/runner.go
deleted file mode 100644
index b02fe5f..0000000
--- a/internal/jobs/runner.go
+++ /dev/null
@@ -1,307 +0,0 @@
-// Package jobs contains the in-process PostgreSQL-backed background runner.
-package jobs
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "runtime/debug"
- "sync/atomic"
- "time"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/sanitize"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/codes"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
- "go.opentelemetry.io/otel/trace/noop"
-)
-
-type Handler interface {
- Type() string
- Handle(ctx context.Context, job model.Job) error
-}
-
-type HandlerFunc func(context.Context, model.Job) error
-type Middleware func(HandlerFunc) HandlerFunc
-
-// Chain applies middleware in declaration order: the first middleware is the
-// outermost boundary.
-func Chain(handler HandlerFunc, middleware ...Middleware) HandlerFunc {
- for index := len(middleware) - 1; index >= 0; index-- {
- if middleware[index] != nil {
- handler = middleware[index](handler)
- }
- }
- return handler
-}
-
-type Error struct {
- Code string
- Retryable bool
- Err error
-}
-
-func (e *Error) Error() string {
- if e.Err == nil {
- return e.Code
- }
- return e.Err.Error()
-}
-func (e *Error) Unwrap() error { return e.Err }
-func Retryable(code string, err error) error { return &Error{Code: code, Retryable: true, Err: err} }
-func Permanent(code string, err error) error { return &Error{Code: code, Retryable: false, Err: err} }
-
-type RunnerOption func(*Runner) error
-
-type Observer interface {
- ObserveJob(jobType, result string, seconds float64)
- SetJobQueueDepth(jobType, status string, depth float64)
-}
-
-func WithHandler(handler Handler) RunnerOption {
- return func(r *Runner) error {
- if handler == nil || handler.Type() == "" {
- return errors.New("job handler type is required")
- }
- if _, exists := r.handlers[handler.Type()]; exists {
- return fmt.Errorf("duplicate job handler %q", handler.Type())
- }
- r.handlers[handler.Type()] = handler
- return nil
- }
-}
-func WithRunnerClock(clock func() time.Time) RunnerOption {
- return func(r *Runner) error {
- if clock != nil {
- r.clock = clock
- }
- return nil
- }
-}
-
-func WithObserver(observer Observer) RunnerOption {
- return func(r *Runner) error {
- r.observer = observer
- return nil
- }
-}
-
-func WithTracing(provider trace.TracerProvider, propagator propagation.TextMapPropagator) RunnerOption {
- return func(r *Runner) error {
- if provider != nil {
- r.tracer = provider.Tracer("github.com/mooncode-ai/mooncode/internal/jobs")
- }
- if propagator != nil {
- r.propagator = propagator
- }
- return nil
- }
-}
-
-func WithMiddleware(middleware ...Middleware) RunnerOption {
- return func(r *Runner) error {
- r.middleware = append(r.middleware, middleware...)
- return nil
- }
-}
-
-type Runner struct {
- store repository.JobStore
- owner string
- cfg config.JobsConfig
- logger zerolog.Logger
- handlers map[string]Handler
- clock func() time.Time
- observer Observer
- ready atomic.Bool
- tracer trace.Tracer
- propagator propagation.TextMapPropagator
- middleware []Middleware
-}
-
-func NewRunner(store repository.JobStore, owner string, cfg config.Config, logger zerolog.Logger, opts ...RunnerOption) (*Runner, error) {
- if store == nil || owner == "" {
- return nil, errors.New("job runner store and owner are required")
- }
- provider := noop.NewTracerProvider()
- runner := &Runner{store: store, owner: owner, cfg: cfg.Jobs, logger: logger, handlers: make(map[string]Handler), clock: time.Now, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/internal/jobs"), propagator: propagation.TraceContext{}}
- for _, option := range opts {
- if option != nil {
- if err := option(runner); err != nil {
- return nil, err
- }
- }
- }
- if len(runner.handlers) == 0 {
- return nil, errors.New("job runner requires at least one handler")
- }
- return runner, nil
-}
-
-func (r *Runner) Ready() bool { return r.ready.Load() }
-
-func (r *Runner) Run(ctx context.Context) error {
- r.ready.Store(true)
- defer r.ready.Store(false)
- types := make([]string, 0, len(r.handlers))
- for jobType := range r.handlers {
- types = append(types, jobType)
- }
- ticker := time.NewTicker(r.cfg.PollInterval)
- defer ticker.Stop()
- for {
- if err := r.refreshQueueDepth(ctx, types); err != nil && !errors.Is(err, context.Canceled) {
- r.logger.Warn().Err(err).Str("operation", "job.queue_depth").Msg("unable to collect background job queue depth")
- }
- job, err := r.store.ClaimJob(ctx, types, r.owner, r.clock().Add(r.cfg.LeaseDuration))
- if err == nil {
- if runErr := r.runJob(ctx, job, r.handlers[job.Type]); runErr != nil && !errors.Is(runErr, context.Canceled) {
- r.logger.Error().Err(runErr).Str("operation", "job.run").Str("job_type", job.Type).Msg("job runner persistence failure")
- }
- continue
- }
- if !errors.Is(err, repository.ErrNotFound) && !errors.Is(err, context.Canceled) {
- r.logger.Warn().Err(err).Str("operation", "job.claim").Msg("unable to claim background job")
- }
- select {
- case <-ctx.Done():
- return nil
- case <-ticker.C:
- }
- }
-}
-
-func (r *Runner) refreshQueueDepth(ctx context.Context, types []string) error {
- if r.observer == nil {
- return nil
- }
- depths, err := r.store.ListJobQueueDepths(ctx, types)
- if err != nil {
- return err
- }
- for _, jobType := range types {
- r.observer.SetJobQueueDepth(jobType, "queued", 0)
- r.observer.SetJobQueueDepth(jobType, "running", 0)
- }
- for _, depth := range depths {
- r.observer.SetJobQueueDepth(depth.Type, depth.Status, float64(depth.Depth))
- }
- return nil
-}
-
-func (r *Runner) runJob(parent context.Context, job model.Job, handler Handler) error {
- startedAt := r.clock()
- ctx, span := r.tracer.Start(parent, "job "+job.Type, trace.WithSpanKind(trace.SpanKindConsumer), trace.WithLinks(r.jobLinks(job)...), trace.WithAttributes(attribute.String("job.type", boundedJobType(job.Type)), attribute.Int("job.attempt", int(job.Attempt))))
- defer span.End()
- ctx, cancel := context.WithCancel(ctx)
- defer cancel()
- done := make(chan error, 1)
- go func() {
- defer func() {
- if recovered := recover(); recovered != nil {
- done <- Retryable("job.handler_panic", fmt.Errorf("handler panic (%T)\n%s", recovered, debug.Stack()))
- }
- }()
- handle := Chain(handler.Handle, r.middleware...)
- done <- handle(ctx, job)
- }()
- ticker := time.NewTicker(r.cfg.RenewInterval)
- defer ticker.Stop()
- var handleErr error
- for {
- select {
- case handleErr = <-done:
- finishErr := r.finish(job, handleErr, r.clock().Sub(startedAt).Seconds())
- if handleErr != nil {
- span.RecordError(handleErr)
- span.SetStatus(codes.Error, "job handler failed")
- }
- if finishErr != nil {
- span.RecordError(finishErr)
- span.SetStatus(codes.Error, "job persistence failed")
- }
- return finishErr
- case <-ticker.C:
- if err := r.store.RenewJobLease(parent, job, r.clock().Add(r.cfg.LeaseDuration)); err != nil {
- cancel()
- <-done
- r.observe(job.Type, "lease_lost", r.clock().Sub(startedAt).Seconds())
- span.RecordError(err)
- span.SetStatus(codes.Error, "job lease lost")
- return err
- }
- case <-parent.Done():
- cancel()
- <-done
- return nil
- }
- }
-}
-
-func (r *Runner) jobLinks(job model.Job) []trace.Link {
- carrier := propagation.MapCarrier{}
- if len(job.TraceContext) == 0 || json.Unmarshal(job.TraceContext, &carrier) != nil {
- return nil
- }
- ctx := r.propagator.Extract(context.Background(), carrier)
- spanContext := trace.SpanContextFromContext(ctx)
- if !spanContext.IsValid() {
- return nil
- }
- return []trace.Link{{SpanContext: spanContext}}
-}
-
-func boundedJobType(value string) string {
- if value == "repository.sync" {
- return value
- }
- return "other"
-}
-
-func (r *Runner) finish(job model.Job, handleErr error, seconds float64) error {
- if handleErr == nil {
- err := r.store.CompleteJob(context.Background(), job)
- if err == nil {
- r.observe(job.Type, "success", seconds)
- }
- return err
- }
- classified := &Error{Code: "job.failed", Retryable: true, Err: handleErr}
- var provided *Error
- if errors.As(handleErr, &provided) {
- classified = provided
- }
- message := safeErrorMessage(classified.Err)
- if classified.Retryable && job.Attempt < job.MaxAttempts {
- delay := r.cfg.RetryBaseDelay * time.Duration(1< maxBytes {
- c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{"error": gin.H{"code": "request.too_large", "message": "Request body is too large", "requestId": RequestIDFromContext(c.Request.Context())}})
- return
- }
- c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
- }
- c.Next()
- }
-}
diff --git a/internal/middleware/body_test.go b/internal/middleware/body_test.go
deleted file mode 100644
index 79edefe..0000000
--- a/internal/middleware/body_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package middleware
-
-import (
- "bytes"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-)
-
-func TestJSONBodyGuard(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := gin.New()
- engine.Use(JSONBodyGuard(8))
- engine.POST("/", func(c *gin.Context) { c.Status(http.StatusNoContent) })
-
- for _, test := range []struct {
- name, contentType, body string
- status int
- }{
- {name: "JSON", contentType: "application/json; charset=utf-8", body: `{}`, status: http.StatusNoContent},
- {name: "empty", contentType: "", body: "", status: http.StatusNoContent},
- {name: "form", contentType: "application/x-www-form-urlencoded", body: "x=1", status: http.StatusUnsupportedMediaType},
- {name: "too large", contentType: "application/json", body: "123456789", status: http.StatusRequestEntityTooLarge},
- } {
- t.Run(test.name, func(t *testing.T) {
- request := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(test.body))
- request.Header.Set("Content-Type", test.contentType)
- response := httptest.NewRecorder()
- engine.ServeHTTP(response, request)
- require.Equal(t, test.status, response.Code)
- })
- }
-}
diff --git a/internal/middleware/identity.go b/internal/middleware/identity.go
deleted file mode 100644
index 9cf9395..0000000
--- a/internal/middleware/identity.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package middleware
-
-import (
- "crypto/subtle"
- "errors"
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/config"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
- "github.com/mooncode-ai/mooncode/internal/service"
- "github.com/rs/zerolog"
-)
-
-func GatewayIdentity(cfg config.IdentityConfig, identities *service.IdentityService, logger zerolog.Logger) gin.HandlerFunc {
- return func(c *gin.Context) {
- provided := c.GetHeader(identityctx.GatewayTokenHeader)
- if subtle.ConstantTimeCompare([]byte(provided), []byte(cfg.GatewayToken)) != 1 {
- abortIdentity(c, http.StatusUnauthorized, "identity.untrusted_gateway", "Authentication required")
- return
- }
- external, err := identityctx.ParseHeaders(c.Request.Header, cfg)
- if err != nil {
- if errors.Is(err, identityctx.ErrSubjectRequired) {
- abortIdentity(c, http.StatusUnauthorized, "identity.subject_required", "Authentication required")
- return
- }
- abortIdentity(c, http.StatusUnauthorized, "identity.invalid_headers", "Authentication required")
- return
- }
- principal, err := identities.Resolve(c.Request.Context(), external)
- if err != nil {
- switch {
- case errors.Is(err, service.ErrSubjectRequired):
- abortIdentity(c, http.StatusUnauthorized, "identity.subject_required", "Authentication required")
- case errors.Is(err, service.ErrRegistrationClosed):
- abortIdentity(c, http.StatusForbidden, "registration.closed", "Registration is closed")
- case errors.Is(err, service.ErrAccountSuspended):
- abortIdentity(c, http.StatusForbidden, "account.suspended", "Account is suspended")
- case errors.Is(err, service.ErrAccountDeleted):
- abortIdentity(c, http.StatusForbidden, "account.unavailable", "Account is unavailable")
- default:
- logger.Error().Err(err).Str("operation", "identity.resolve").Msg("failed to resolve authenticated identity")
- abortIdentity(c, http.StatusInternalServerError, "identity.resolve_failed", "Unable to initialize user")
- }
- return
- }
- if principal.User.Status == "pending" && !pendingRouteAllowed(c.Request.URL.Path) {
- abortIdentity(c, http.StatusForbidden, "account.onboarding_required", "Complete account registration")
- return
- }
- c.Request = c.Request.WithContext(identityctx.WithPrincipal(c.Request.Context(), principal))
- c.Next()
- }
-}
-
-func pendingRouteAllowed(path string) bool {
- path = strings.TrimSuffix(path, "/")
- switch path {
- case "/api/v1/session", "/api/v1/csrf", "/api/v1/onboarding", "/api/v1/onboarding/complete", "/api/v1/invitations/accept":
- return true
- default:
- return false
- }
-}
-
-func abortIdentity(c *gin.Context, status int, code, message string) {
- c.AbortWithStatusJSON(status, gin.H{"error": gin.H{
- "code": code, "message": message, "requestId": RequestIDFromContext(c.Request.Context()),
- }})
-}
diff --git a/internal/middleware/identity_test.go b/internal/middleware/identity_test.go
deleted file mode 100644
index 7b61d36..0000000
--- a/internal/middleware/identity_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package middleware
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/repository/memory"
- "github.com/mooncode-ai/mooncode/internal/service"
- "github.com/rs/zerolog"
- "github.com/stretchr/testify/require"
-)
-
-func TestGatewayIdentityRequiresOAuthSubjectAndRestrictsPendingRoutes(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cfg, err := config.NewLoader().Load("", map[string]any{"registration.mode": "public"})
- require.NoError(t, err)
- identities := service.NewIdentityService(memory.New(), cfg)
- engine := gin.New()
- engine.Use(GatewayIdentity(cfg.Identity, identities, zerolog.New(io.Discard)))
- engine.GET("/api/v1/session", func(c *gin.Context) { c.Status(http.StatusNoContent) })
- engine.GET("/api/v1/workspaces", func(c *gin.Context) { c.Status(http.StatusNoContent) })
-
- missingSubject := identityRequest(cfg, "/api/v1/session", false)
- missingResponse := httptest.NewRecorder()
- engine.ServeHTTP(missingResponse, missingSubject)
- require.Equal(t, http.StatusUnauthorized, missingResponse.Code)
- require.Contains(t, missingResponse.Body.String(), "identity.subject_required")
-
- allowedResponse := httptest.NewRecorder()
- engine.ServeHTTP(allowedResponse, identityRequest(cfg, "/api/v1/session", true))
- require.Equal(t, http.StatusNoContent, allowedResponse.Code)
-
- blockedResponse := httptest.NewRecorder()
- engine.ServeHTTP(blockedResponse, identityRequest(cfg, "/api/v1/workspaces", true))
- require.Equal(t, http.StatusForbidden, blockedResponse.Code)
- require.Contains(t, blockedResponse.Body.String(), "account.onboarding_required")
-}
-
-func TestGatewayIdentityDoesNotPersistUnknownIdentityWhenRegistrationDisabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cfg, err := config.NewLoader().Load("", map[string]any{"registration.mode": "disabled"})
- require.NoError(t, err)
- identities := service.NewIdentityService(memory.New(), cfg)
- engine := gin.New()
- engine.Use(GatewayIdentity(cfg.Identity, identities, zerolog.New(io.Discard)))
- engine.GET("/api/v1/session", func(c *gin.Context) { c.Status(http.StatusNoContent) })
-
- response := httptest.NewRecorder()
- engine.ServeHTTP(response, identityRequest(cfg, "/api/v1/session", true))
- require.Equal(t, http.StatusForbidden, response.Code)
- require.Contains(t, response.Body.String(), "registration.closed")
-}
-
-func identityRequest(cfg config.Config, path string, includeSubject bool) *http.Request {
- request := httptest.NewRequest(http.MethodGet, path, nil)
- request.Header.Set("X-MoonCode-Gateway-Token", cfg.Identity.GatewayToken)
- request.Header.Set("Remote-User", "moon")
- request.Header.Set("Remote-Email", "moon@example.com")
- if includeSubject {
- request.Header.Set("Remote-Sub", "42")
- }
- return request
-}
diff --git a/internal/middleware/rate_limit.go b/internal/middleware/rate_limit.go
deleted file mode 100644
index 6747431..0000000
--- a/internal/middleware/rate_limit.go
+++ /dev/null
@@ -1,114 +0,0 @@
-package middleware
-
-import (
- "net"
- "net/http"
- "sync"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/config"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
-)
-
-type bucket struct {
- tokens float64
- updatedAt time.Time
- seenAt time.Time
-}
-type RateLimitObserver interface{ ObserveRateLimited(scope, operation string) }
-type RateLimiter struct {
- mu sync.Mutex
- cfg config.RateLimitConfig
- entries map[string]*bucket
- clock func() time.Time
- metrics RateLimitObserver
-}
-type RateLimiterOption func(*RateLimiter)
-
-func WithRateLimitClock(clock func() time.Time) RateLimiterOption {
- return func(limiter *RateLimiter) {
- if clock != nil {
- limiter.clock = clock
- }
- }
-}
-func NewRateLimiter(cfg config.RateLimitConfig, metrics RateLimitObserver, opts ...RateLimiterOption) *RateLimiter {
- limiter := &RateLimiter{cfg: cfg, entries: make(map[string]*bucket), clock: time.Now, metrics: metrics}
- for _, option := range opts {
- if option != nil {
- option(limiter)
- }
- }
- return limiter
-}
-func (l *RateLimiter) Middleware() gin.HandlerFunc {
- return func(c *gin.Context) {
- principal, _ := identityctx.PrincipalFromContext(c.Request.Context())
- host, _, _ := net.SplitHostPort(c.Request.RemoteAddr)
- checks := []struct {
- scope, key string
- limit config.LimitConfig
- }{{"ip", host, l.cfg.PerIP}, {"user", principal.User.ID.String(), l.cfg.PerUser}}
- if workspaceID := c.Param("workspaceID"); workspaceID != "" {
- checks = append(checks, struct {
- scope, key string
- limit config.LimitConfig
- }{"workspace", workspaceID, l.cfg.PerWorkspace})
- }
- for _, check := range checks {
- if check.key != "" && !l.allow(check.scope+":"+check.key, check.limit) {
- operation := "read"
- if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead && c.Request.Method != http.MethodOptions {
- operation = "write"
- }
- if l.metrics != nil {
- l.metrics.ObserveRateLimited(check.scope, operation)
- }
- c.Header("Retry-After", "1")
- c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": gin.H{"code": "rate_limit.exceeded", "message": "Too many requests", "requestId": RequestIDFromContext(c.Request.Context())}})
- return
- }
- }
- c.Next()
- }
-}
-func (l *RateLimiter) allow(key string, limit config.LimitConfig) bool {
- l.mu.Lock()
- defer l.mu.Unlock()
- now := l.clock()
- entry := l.entries[key]
- if entry == nil {
- if len(l.entries) >= l.cfg.MaxEntries {
- l.evict(now)
- }
- entry = &bucket{tokens: limit.Burst, updatedAt: now}
- l.entries[key] = entry
- }
- elapsed := now.Sub(entry.updatedAt).Seconds()
- entry.tokens = min(limit.Burst, entry.tokens+elapsed*limit.Rate)
- entry.updatedAt, entry.seenAt = now, now
- if entry.tokens < 1 {
- return false
- }
- entry.tokens--
- return true
-}
-func (l *RateLimiter) evict(now time.Time) {
- for key, entry := range l.entries {
- if now.Sub(entry.seenAt) >= l.cfg.EntryTTL {
- delete(l.entries, key)
- }
- }
- if len(l.entries) < l.cfg.MaxEntries {
- return
- }
- var oldestKey string
- var oldest time.Time
- for key, entry := range l.entries {
- if oldestKey == "" || entry.seenAt.Before(oldest) {
- oldestKey, oldest = key, entry.seenAt
- }
- }
- delete(l.entries, oldestKey)
-}
diff --git a/internal/middleware/rate_limit_test.go b/internal/middleware/rate_limit_test.go
deleted file mode 100644
index 64716d7..0000000
--- a/internal/middleware/rate_limit_test.go
+++ /dev/null
@@ -1,83 +0,0 @@
-package middleware
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- identityctx "github.com/mooncode-ai/mooncode/internal/identity"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/stretchr/testify/require"
-)
-
-func TestRateLimiterRefillsTokens(t *testing.T) {
- now := time.Unix(100, 0)
- limiter := NewRateLimiter(testRateLimitConfig(), nil, WithRateLimitClock(func() time.Time { return now }))
- limit := config.LimitConfig{Rate: 1, Burst: 1}
- require.True(t, limiter.allow("ip:one", limit))
- require.False(t, limiter.allow("ip:one", limit))
- now = now.Add(time.Second)
- require.True(t, limiter.allow("ip:one", limit))
-}
-
-func TestRateLimiterChecksIPUserAndWorkspace(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cfg := testRateLimitConfig()
- cfg.PerIP.Burst, cfg.PerUser.Burst, cfg.PerWorkspace.Burst = 2, 2, 1
- limiter := NewRateLimiter(cfg, nil)
- userID := uuid.MustParse("018f0000-0000-7000-8000-000000000001")
- engine := gin.New()
- engine.Use(func(c *gin.Context) {
- principal := model.Principal{User: model.User{ID: userID}}
- c.Request = c.Request.WithContext(identityctx.WithPrincipal(c.Request.Context(), principal))
- c.Next()
- }, limiter.Middleware())
- engine.GET("/workspaces/:workspaceID/resources", func(c *gin.Context) { c.Status(http.StatusNoContent) })
-
- workspaceID := "018f0000-0000-7000-8000-000000000002"
- for index, expected := range []int{http.StatusNoContent, http.StatusTooManyRequests} {
- request := httptest.NewRequest(http.MethodGet, "/workspaces/"+workspaceID+"/resources", nil)
- request.RemoteAddr = "192.0.2.5:1234"
- response := httptest.NewRecorder()
- engine.ServeHTTP(response, request)
- require.Equal(t, expected, response.Code, "request %d", index+1)
- if expected == http.StatusTooManyRequests {
- require.Equal(t, "1", response.Header().Get("Retry-After"))
- require.JSONEq(t, `{"error":{"code":"rate_limit.exceeded","message":"Too many requests","requestId":""}}`, response.Body.String())
- }
- }
- require.Contains(t, limiter.entries, "ip:192.0.2.5")
- require.Contains(t, limiter.entries, "user:"+userID.String())
- require.Contains(t, limiter.entries, "workspace:"+workspaceID)
-}
-
-func TestRateLimiterEvictsExpiredAndOldestEntries(t *testing.T) {
- now := time.Unix(100, 0)
- cfg := testRateLimitConfig()
- cfg.EntryTTL, cfg.MaxEntries = time.Minute, 2
- limiter := NewRateLimiter(cfg, nil, WithRateLimitClock(func() time.Time { return now }))
- limit := config.LimitConfig{Rate: 1, Burst: 1}
- require.True(t, limiter.allow("one", limit))
- now = now.Add(time.Second)
- require.True(t, limiter.allow("two", limit))
- now = now.Add(time.Second)
- require.True(t, limiter.allow("three", limit))
- require.NotContains(t, limiter.entries, "one")
- require.Len(t, limiter.entries, 2)
-
- now = now.Add(2 * time.Minute)
- require.True(t, limiter.allow("four", limit))
- require.NotContains(t, limiter.entries, "two")
- require.NotContains(t, limiter.entries, "three")
-}
-
-func testRateLimitConfig() config.RateLimitConfig {
- return config.RateLimitConfig{
- PerIP: config.LimitConfig{Rate: 100, Burst: 100}, PerUser: config.LimitConfig{Rate: 100, Burst: 100},
- PerWorkspace: config.LimitConfig{Rate: 100, Burst: 100}, EntryTTL: time.Minute, MaxEntries: 100,
- }
-}
diff --git a/internal/middleware/request_id.go b/internal/middleware/request_id.go
deleted file mode 100644
index d002e98..0000000
--- a/internal/middleware/request_id.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package middleware
-
-import (
- "context"
- "crypto/rand"
- "encoding/hex"
- "fmt"
- "net/http"
- "sync/atomic"
- "time"
-
- "github.com/gin-gonic/gin"
-)
-
-const RequestIDHeader = "X-Request-ID"
-
-type requestIDKey struct{}
-
-var fallbackRequestID atomic.Uint64
-
-func RequestID() gin.HandlerFunc {
- return func(c *gin.Context) {
- requestID := RequestIDFromContext(c.Request.Context())
- if requestID == "" {
- requestID = c.GetHeader(RequestIDHeader)
- }
- if !validRequestID(requestID) {
- requestID = newRequestID()
- }
- ctx := context.WithValue(c.Request.Context(), requestIDKey{}, requestID)
- c.Request = c.Request.WithContext(ctx)
- c.Header(RequestIDHeader, requestID)
- c.Next()
- }
-}
-
-func RequestIDHTTP(next http.Handler) http.Handler {
- return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
- requestID := request.Header.Get(RequestIDHeader)
- if !validRequestID(requestID) {
- requestID = newRequestID()
- }
- writer.Header().Set(RequestIDHeader, requestID)
- ctx := context.WithValue(request.Context(), requestIDKey{}, requestID)
- next.ServeHTTP(writer, request.WithContext(ctx))
- })
-}
-
-func RequestIDFromContext(ctx context.Context) string {
- requestID, _ := ctx.Value(requestIDKey{}).(string)
- return requestID
-}
-
-func validRequestID(value string) bool {
- if len(value) < 8 || len(value) > 128 {
- return false
- }
- for index := 0; index < len(value); index++ {
- character := value[index]
- if (character >= 'a' && character <= 'z') ||
- (character >= 'A' && character <= 'Z') ||
- (character >= '0' && character <= '9') ||
- character == '-' || character == '_' || character == '.' {
- continue
- }
- return false
- }
- return true
-}
-
-func newRequestID() string {
- var value [16]byte
- if _, err := rand.Read(value[:]); err != nil {
- return fmt.Sprintf("fallback-%x-%x", time.Now().UnixNano(), fallbackRequestID.Add(1))
- }
- return hex.EncodeToString(value[:])
-}
diff --git a/internal/middleware/request_id_test.go b/internal/middleware/request_id_test.go
deleted file mode 100644
index bfa4b6b..0000000
--- a/internal/middleware/request_id_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package middleware
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-)
-
-func TestRequestIDPreservesValidValue(t *testing.T) {
- gin.SetMode(gin.TestMode)
- engine := gin.New()
- engine.Use(RequestID())
- engine.GET("/", func(c *gin.Context) {
- c.String(http.StatusOK, RequestIDFromContext(c.Request.Context()))
- })
-
- request := httptest.NewRequest(http.MethodGet, "/", nil)
- request.Header.Set(RequestIDHeader, "request-123")
- response := httptest.NewRecorder()
- engine.ServeHTTP(response, request)
-
- require.Equal(t, http.StatusOK, response.Code)
- require.Equal(t, "request-123", response.Body.String())
- require.Equal(t, "request-123", response.Header().Get(RequestIDHeader))
-}
-
-func TestRequestIDReplacesUnsafeValue(t *testing.T) {
- require.False(t, validRequestID("unsafe request id"))
- require.Len(t, newRequestID(), 32)
-}
diff --git a/internal/model/channel.go b/internal/model/channel.go
deleted file mode 100644
index 690d4ea..0000000
--- a/internal/model/channel.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package model
-
-import (
- "encoding/json"
- "time"
-
- "github.com/google/uuid"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
-)
-
-type ChannelConfig struct {
- Values map[string]any `json:"values"`
- SenderAllowList []string `json:"senderAllowList"`
- GroupPolicy channelcore.GroupPolicy `json:"groupPolicy"`
-}
-
-type ChannelInstance struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspaceId"`
- Type string `json:"type"`
- Name string `json:"name"`
- Enabled bool `json:"enabled"`
- Config ChannelConfig `json:"config"`
- SecretRef *uuid.UUID `json:"-"`
- SecretConfigured bool `json:"secretConfigured"`
- ConfigVersion int64 `json:"configVersion"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type ChannelLease struct {
- ChannelInstanceID uuid.UUID
- Owner string
- LeaseUntil time.Time
- FencingToken int64
-}
-type ChannelRuntimeStatus struct {
- ChannelInstanceID uuid.UUID `json:"channelInstanceId"`
- State string `json:"state"`
- BackendInstanceID string `json:"backendInstanceId,omitempty"`
- FencingToken int64 `json:"-"`
- LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"`
- LastErrorCode string `json:"lastErrorCode,omitempty"`
- LastErrorMessage string `json:"lastErrorMessage,omitempty"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type IMMessage struct {
- ID uuid.UUID `json:"id"`
- ChannelInstanceID uuid.UUID `json:"channelInstanceId"`
- ChannelName string `json:"channelName"`
- ChannelType string `json:"channelType"`
- ConversationID uuid.UUID `json:"conversationId"`
- ConversationExternalID string `json:"conversationExternalId"`
- ConversationType string `json:"conversationType"`
- SenderCanonicalID string `json:"senderCanonicalId"`
- SenderDisplayName string `json:"senderDisplayName"`
- ExternalMessageID string `json:"externalMessageId"`
- Content json.RawMessage `json:"content"`
- OccurredAt time.Time `json:"occurredAt"`
- ReceivedAt time.Time `json:"receivedAt"`
-}
-type IMConversation struct {
- ID uuid.UUID `json:"id"`
- ChannelInstanceID uuid.UUID `json:"channelInstanceId"`
- ChannelName string `json:"channelName"`
- ChannelType string `json:"channelType"`
- ExternalID string `json:"externalId"`
- Type string `json:"type"`
- Title string `json:"title"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
diff --git a/internal/model/identity.go b/internal/model/identity.go
deleted file mode 100644
index a2d74e5..0000000
--- a/internal/model/identity.go
+++ /dev/null
@@ -1,104 +0,0 @@
-package model
-
-import (
- "time"
-
- "github.com/google/uuid"
-)
-
-type ExternalIdentity struct {
- Issuer string
- Subject string
- Username string
- Email string
- DisplayName string
-}
-
-type User struct {
- ID uuid.UUID `json:"id"`
- Issuer string `json:"issuer"`
- ExternalSubject string `json:"externalSubject"`
- Username string `json:"username"`
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
- Status string `json:"status"`
- TermsVersion string `json:"termsVersion"`
- PrivacyVersion string `json:"privacyVersion"`
- ActivatedAt *time.Time `json:"activatedAt,omitempty"`
- SuspendedAt *time.Time `json:"suspendedAt,omitempty"`
- LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
- AgreementsAcceptedAt *time.Time `json:"agreementsAcceptedAt,omitempty"`
- DeletedAt *time.Time `json:"deletedAt,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type Workspace struct {
- ID uuid.UUID `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Kind string `json:"kind"`
- CreatedBy uuid.UUID `json:"createdBy"`
- Role string `json:"role"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type Principal struct {
- User User `json:"user"`
-}
-
-type Session struct {
- User User `json:"user"`
- Workspaces []Workspace `json:"workspaces"`
- Auth SessionAuth `json:"auth"`
- Registration SessionRegistration `json:"registration"`
-}
-
-type SessionAuth struct {
- LogoutURL string `json:"logoutUrl"`
-}
-
-type SessionRegistration struct {
- Mode string `json:"mode"`
- TermsVersion string `json:"termsVersion"`
- PrivacyVersion string `json:"privacyVersion"`
-}
-
-type WorkspaceInvitation struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspaceId"`
- Email string `json:"email"`
- Role string `json:"role"`
- InvitedBy uuid.UUID `json:"invitedBy"`
- ExpiresAt time.Time `json:"expiresAt"`
- AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
- AcceptedBy *uuid.UUID `json:"acceptedBy,omitempty"`
- RevokedAt *time.Time `json:"revokedAt,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
- Token string `json:"token,omitempty"`
-}
-
-type AccountAuditLog struct {
- ID uuid.UUID `json:"id"`
- UserID uuid.UUID `json:"userId"`
- ActorUserID *uuid.UUID `json:"actorUserId,omitempty"`
- Action string `json:"action"`
- Result string `json:"result"`
- Provider string `json:"provider"`
- RequestID string `json:"requestId"`
- Metadata map[string]any `json:"metadata"`
- OccurredAt time.Time `json:"occurredAt"`
-}
-
-type WorkspaceMember struct {
- WorkspaceID uuid.UUID `json:"workspaceId"`
- UserID uuid.UUID `json:"userId"`
- Role string `json:"role"`
- Username string `json:"username"`
- Email string `json:"email"`
- DisplayName string `json:"displayName"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
diff --git a/internal/model/repository.go b/internal/model/repository.go
deleted file mode 100644
index d752b41..0000000
--- a/internal/model/repository.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package model
-
-import (
- "encoding/json"
- "time"
-
- "github.com/google/uuid"
-)
-
-type SCMConnection struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspaceId"`
- Type string `json:"type"`
- Name string `json:"name"`
- BaseURL string `json:"baseUrl"`
- AuthType string `json:"authType"`
- SecretRef *uuid.UUID `json:"-"`
- SecretConfigured bool `json:"secretConfigured"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type Repository struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspaceId"`
- ConnectionID *uuid.UUID `json:"connectionId,omitempty"`
- Name string `json:"name"`
- CloneURL string `json:"cloneUrl"`
- NormalizedURL string `json:"normalizedUrl"`
- Ref string `json:"ref"`
- CurrentCommitSHA string `json:"currentCommitSha,omitempty"`
- State string `json:"state"`
- LastErrorCode string `json:"lastErrorCode,omitempty"`
- LastErrorMessage string `json:"lastErrorMessage,omitempty"`
- SyncedAt *time.Time `json:"syncedAt,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type Job struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspaceId"`
- Type string `json:"type"`
- Payload json.RawMessage `json:"-"`
- Status string `json:"status"`
- Attempt int32 `json:"attempt"`
- MaxAttempts int32 `json:"maxAttempts"`
- LeaseOwner string `json:"-"`
- LeaseUntil *time.Time `json:"-"`
- FencingToken int64 `json:"-"`
- RunAfter time.Time `json:"runAfter"`
- TraceContext json.RawMessage `json:"-"`
- LastErrorCode string `json:"lastErrorCode,omitempty"`
- LastErrorMessage string `json:"lastErrorMessage,omitempty"`
- CreatedAt time.Time `json:"createdAt"`
- StartedAt *time.Time `json:"startedAt,omitempty"`
- FinishedAt *time.Time `json:"finishedAt,omitempty"`
- UpdatedAt time.Time `json:"updatedAt"`
-}
-
-type JobQueueDepth struct {
- Type string
- Status string
- Depth int64
-}
-
-type WorkspaceOverview struct {
- RepositoryCount int64 `json:"repositoryCount"`
- ActiveChannelCount int64 `json:"activeChannelCount"`
- FailedJobCount int64 `json:"failedJobCount"`
- RecentMessages []IMMessage `json:"recentMessages"`
-}
-
-type RepositorySyncPayload struct {
- RepositoryID uuid.UUID `json:"repositoryId"`
-}
-
-type OutboxEvent struct {
- ID uuid.UUID
- WorkspaceID uuid.UUID
- Aggregate string
- AggregateID uuid.UUID
- Type string
- Payload json.RawMessage
- TraceContext json.RawMessage
- LeaseOwner string
- LeaseUntil *time.Time
- Attempt int32
- CreatedAt time.Time
-}
-
-type AuditLog struct {
- ID uuid.UUID `json:"id"`
- WorkspaceID uuid.UUID `json:"workspaceId"`
- ActorUserID *uuid.UUID `json:"actorUserId,omitempty"`
- Action string `json:"action"`
- ResourceType string `json:"resourceType"`
- ResourceID *uuid.UUID `json:"resourceId,omitempty"`
- Result string `json:"result"`
- Metadata json.RawMessage `json:"metadata"`
- OccurredAt time.Time `json:"occurredAt"`
-}
diff --git a/internal/observability/http.go b/internal/observability/http.go
deleted file mode 100644
index 7ddb0d4..0000000
--- a/internal/observability/http.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package observability
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "runtime/debug"
- "strconv"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/middleware"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/codes"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
-)
-
-func TraceMiddleware(provider trace.TracerProvider, propagator propagation.TextMapPropagator) gin.HandlerFunc {
- tracer := provider.Tracer("github.com/mooncode-ai/mooncode/internal/observability/http")
- return func(c *gin.Context) {
- ctx := propagator.Extract(c.Request.Context(), propagation.HeaderCarrier(c.Request.Header))
- ctx, span := tracer.Start(ctx, "HTTP "+normalizeMethod(c.Request.Method),
- trace.WithSpanKind(trace.SpanKindServer),
- trace.WithAttributes(attribute.String("http.request.method", normalizeMethod(c.Request.Method))),
- )
- defer span.End()
-
- c.Request = c.Request.WithContext(ctx)
- c.Next()
-
- route := routeTemplate(c)
- span.SetName(normalizeMethod(c.Request.Method) + " " + route)
- span.SetAttributes(
- attribute.String("http.route", route),
- attribute.Int("http.response.status_code", c.Writer.Status()),
- )
- if c.Writer.Status() >= http.StatusInternalServerError {
- span.RecordError(errors.New("HTTP request failed"))
- span.SetStatus(codes.Error, http.StatusText(c.Writer.Status()))
- }
- }
-}
-
-func AccessLogMiddleware(base zerolog.Logger) gin.HandlerFunc {
- return func(c *gin.Context) {
- startedAt := time.Now()
- c.Next()
-
- logger := contextualLogger(c.Request.Context(), base)
- logger.Info().
- Str("operation", "http.request").
- Str("http_method", normalizeMethod(c.Request.Method)).
- Str("http_route", routeTemplate(c)).
- Int("http_status", c.Writer.Status()).
- Int("response_bytes", c.Writer.Size()).
- Dur("duration", time.Since(startedAt)).
- Msg("HTTP request completed")
- }
-}
-
-func MetricsMiddleware(metrics *Metrics) gin.HandlerFunc {
- return func(c *gin.Context) {
- startedAt := time.Now()
- c.Next()
- metrics.Observe(
- normalizeMethod(c.Request.Method),
- routeTemplate(c),
- statusClass(c.Writer.Status()),
- time.Since(startedAt).Seconds(),
- )
- }
-}
-
-func RecoveryMiddleware(base zerolog.Logger) gin.HandlerFunc {
- return func(c *gin.Context) {
- defer func() {
- if recovered := recover(); recovered != nil {
- logger := contextualLogger(c.Request.Context(), base)
- logger.Error().
- Str("operation", "http.request").
- Str("error_code", "http.panic").
- Str("panic_type", fmt.Sprintf("%T", recovered)).
- Str("stack", string(debug.Stack())).
- Msg("recovered HTTP handler panic")
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
- "error": gin.H{
- "code": "internal.error",
- "message": "Internal server error",
- "requestId": middleware.RequestIDFromContext(c.Request.Context()),
- },
- })
- }
- }()
- c.Next()
- }
-}
-
-func AccessLogHTTP(base zerolog.Logger, next http.Handler) http.Handler {
- return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
- startedAt := time.Now()
- response := &statusRecorder{ResponseWriter: writer, status: http.StatusOK}
- next.ServeHTTP(response, request)
- logger := contextualLogger(request.Context(), base)
- logger.Info().
- Str("operation", "admin.request").
- Str("http_method", normalizeMethod(request.Method)).
- Str("http_route", adminRoute(request.URL.Path)).
- Int("http_status", response.status).
- Dur("duration", time.Since(startedAt)).
- Msg("admin HTTP request completed")
- })
-}
-
-func RecoveryHTTP(base zerolog.Logger, next http.Handler) http.Handler {
- return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
- defer func() {
- if recovered := recover(); recovered != nil {
- logger := contextualLogger(request.Context(), base)
- logger.Error().
- Str("operation", "admin.request").
- Str("error_code", "admin.panic").
- Str("panic_type", fmt.Sprintf("%T", recovered)).
- Str("stack", string(debug.Stack())).
- Msg("recovered admin handler panic")
- http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
- }
- }()
- next.ServeHTTP(writer, request)
- })
-}
-
-func contextualLogger(ctx context.Context, base zerolog.Logger) zerolog.Logger {
- logContext := base.With()
- if requestID := middleware.RequestIDFromContext(ctx); requestID != "" {
- logContext = logContext.Str("request_id", requestID)
- }
- spanContext := trace.SpanContextFromContext(ctx)
- if spanContext.IsValid() {
- logContext = logContext.
- Str("trace_id", spanContext.TraceID().String()).
- Str("span_id", spanContext.SpanID().String())
- }
- return logContext.Logger()
-}
-
-func routeTemplate(c *gin.Context) string {
- if route := c.FullPath(); route != "" {
- return route
- }
- return "unknown"
-}
-
-func adminRoute(path string) string {
- switch path {
- case "/livez", "/readyz", "/metrics":
- return path
- default:
- return "unknown"
- }
-}
-
-func normalizeMethod(method string) string {
- switch method {
- case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut,
- http.MethodPatch, http.MethodDelete, http.MethodConnect, http.MethodOptions, http.MethodTrace:
- return method
- default:
- return "OTHER"
- }
-}
-
-func statusClass(status int) string {
- if status < 100 || status > 599 {
- return "unknown"
- }
- return strconv.Itoa(status/100) + "xx"
-}
-
-type statusRecorder struct {
- http.ResponseWriter
- status int
- wroteHeader bool
-}
-
-func (r *statusRecorder) WriteHeader(status int) {
- if r.wroteHeader {
- return
- }
- r.wroteHeader = true
- r.status = status
- r.ResponseWriter.WriteHeader(status)
-}
-
-func (r *statusRecorder) Write(body []byte) (int, error) {
- if !r.wroteHeader {
- r.WriteHeader(http.StatusOK)
- }
- return r.ResponseWriter.Write(body)
-}
diff --git a/internal/observability/http_test.go b/internal/observability/http_test.go
deleted file mode 100644
index a0c6df5..0000000
--- a/internal/observability/http_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package observability
-
-import (
- "bytes"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/mooncode-ai/mooncode/internal/middleware"
- "github.com/prometheus/client_golang/prometheus"
- "github.com/rs/zerolog"
- "github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel/propagation"
- sdktrace "go.opentelemetry.io/otel/sdk/trace"
- "go.opentelemetry.io/otel/sdk/trace/tracetest"
-)
-
-func TestHTTPBoundaryObservesRecoveredPanic(t *testing.T) {
- gin.SetMode(gin.TestMode)
- var logs bytes.Buffer
- logger := zerolog.New(&logs)
- registry := prometheus.NewRegistry()
- metrics := NewMetrics(registry)
- recorder := tracetest.NewSpanRecorder()
- provider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder))
- defer func() { require.NoError(t, provider.Shutdown(t.Context())) }()
-
- engine := gin.New()
- engine.Use(
- middleware.RequestID(),
- TraceMiddleware(provider, propagation.TraceContext{}),
- AccessLogMiddleware(logger),
- MetricsMiddleware(metrics),
- RecoveryMiddleware(logger),
- )
- engine.GET("/panic/:resourceID", func(*gin.Context) { panic("boom") })
- response := httptest.NewRecorder()
- engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/panic/private-resource-id", nil))
-
- require.Equal(t, http.StatusInternalServerError, response.Code)
- require.Contains(t, response.Body.String(), `"code":"internal.error"`)
- require.Contains(t, logs.String(), `"error_code":"http.panic"`)
- require.Contains(t, logs.String(), `"http_status":500`)
- spans := recorder.Ended()
- require.Len(t, spans, 1)
- require.Equal(t, "GET /panic/:resourceID", spans[0].Name())
- require.True(t, spans[0].Status().Code.String() == "Error")
-
- families, err := registry.Gather()
- require.NoError(t, err)
- for _, family := range families {
- if family.GetName() == "mooncode_http_requests_total" {
- require.Len(t, family.Metric, 1)
- for _, label := range family.Metric[0].Label {
- require.NotEqual(t, "private-resource-id", label.GetValue())
- }
- return
- }
- }
- t.Fatal("mooncode_http_requests_total was not collected")
-}
diff --git a/internal/observability/io.go b/internal/observability/io.go
deleted file mode 100644
index 1ca3226..0000000
--- a/internal/observability/io.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package observability
-
-import (
- "context"
- "errors"
- "io"
- "time"
-
- gitcore "github.com/mooncode-ai/mooncode/pkg/git"
- "github.com/mooncode-ai/mooncode/pkg/storage"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/codes"
- "go.opentelemetry.io/otel/trace"
-)
-
-func InstrumentGit(metrics *Metrics, provider trace.TracerProvider) gitcore.Wrapper {
- return func(next gitcore.Client) gitcore.Client {
- return &gitClient{next: next, metrics: metrics, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/pkg/git")}
- }
-}
-
-type gitClient struct {
- next gitcore.Client
- metrics *Metrics
- tracer trace.Tracer
-}
-
-func (c *gitClient) Probe(ctx context.Context, request gitcore.ProbeRequest) error {
- ctx, span := c.tracer.Start(ctx, "git.probe", trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attribute.String("scm.provider", boundedProvider(request.Provider))))
- started := time.Now()
- err := c.next.Probe(ctx, request)
- c.finish(span, "probe", request.Provider, started, err)
- return err
-}
-
-func (c *gitClient) Fetch(ctx context.Context, request gitcore.FetchRequest) (gitcore.FetchResult, error) {
- ctx, span := c.tracer.Start(ctx, "git.fetch", trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attribute.String("scm.provider", boundedProvider(request.Provider))))
- started := time.Now()
- result, err := c.next.Fetch(ctx, request)
- c.finish(span, "fetch", request.Provider, started, err)
- return result, err
-}
-
-func (c *gitClient) finish(span trace.Span, operation, provider string, started time.Time, err error) {
- result := operationResult(err)
- c.metrics.ObserveGit(operation, provider, result, time.Since(started).Seconds())
- if err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, result)
- }
- span.End()
-}
-
-func InstrumentStorage(backend string, metrics *Metrics, provider trace.TracerProvider) storage.Wrapper {
- return func(next storage.Store) storage.Store {
- return &storageStore{next: next, backend: oneOf(backend, "s3", "filesystem"), metrics: metrics, tracer: provider.Tracer("github.com/mooncode-ai/mooncode/pkg/storage")}
- }
-}
-
-type storageStore struct {
- next storage.Store
- backend string
- metrics *Metrics
- tracer trace.Tracer
-}
-
-func (s *storageStore) Put(ctx context.Context, location storage.Location, body io.Reader, size int64, opts ...storage.PutOption) (storage.Metadata, error) {
- ctx, finish := s.start(ctx, "put")
- metadata, err := s.next.Put(ctx, location, body, size, opts...)
- finish(err)
- return metadata, err
-}
-
-func (s *storageStore) Open(ctx context.Context, location storage.Location, opts ...storage.OpenOption) (io.ReadCloser, error) {
- ctx, finish := s.start(ctx, "open")
- reader, err := s.next.Open(ctx, location, opts...)
- finish(err)
- return reader, err
-}
-
-func (s *storageStore) Stat(ctx context.Context, location storage.Location) (storage.Metadata, error) {
- ctx, finish := s.start(ctx, "stat")
- metadata, err := s.next.Stat(ctx, location)
- finish(err)
- return metadata, err
-}
-
-func (s *storageStore) Delete(ctx context.Context, location storage.Location) error {
- ctx, finish := s.start(ctx, "delete")
- err := s.next.Delete(ctx, location)
- finish(err)
- return err
-}
-
-func (s *storageStore) start(ctx context.Context, operation string) (context.Context, func(error)) {
- ctx, span := s.tracer.Start(ctx, "storage."+operation, trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(attribute.String("storage.backend", s.backend), attribute.String("storage.operation", operation)))
- started := time.Now()
- return ctx, func(err error) {
- result := storageResult(err)
- s.metrics.ObserveStorage(s.backend, operation, result, time.Since(started).Seconds())
- if err != nil {
- span.RecordError(err)
- span.SetStatus(codes.Error, result)
- }
- span.End()
- }
-}
-
-func operationResult(err error) string {
- if err == nil {
- return "success"
- }
- if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return "cancelled"
- }
- return "error"
-}
-
-func storageResult(err error) string {
- if errors.Is(err, storage.ErrNotFound) {
- return "not_found"
- }
- if errors.Is(err, storage.ErrAlreadyExists) {
- return "conflict"
- }
- return operationResult(err)
-}
-
-func boundedProvider(provider string) string { return oneOf(provider, "github", "gitlab", "generic") }
-
-var _ gitcore.Client = (*gitClient)(nil)
-var _ storage.Store = (*storageStore)(nil)
diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go
deleted file mode 100644
index b7f5594..0000000
--- a/internal/observability/metrics.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package observability
-
-import "github.com/prometheus/client_golang/prometheus"
-
-type Metrics struct {
- requests *prometheus.CounterVec
- httpDuration *prometheus.HistogramVec
- rateLimited *prometheus.CounterVec
- jobs *prometheus.CounterVec
- jobDuration *prometheus.HistogramVec
- jobQueueDepth *prometheus.GaugeVec
- channelRuntime *prometheus.GaugeVec
- channelReconnects *prometheus.CounterVec
- channelMessages *prometheus.CounterVec
- gitOperations *prometheus.CounterVec
- gitDuration *prometheus.HistogramVec
- storageOperations *prometheus.CounterVec
- storageDuration *prometheus.HistogramVec
- outboxBacklog prometheus.Gauge
-}
-
-func NewMetrics(registerer prometheus.Registerer) *Metrics {
- metrics := &Metrics{
- requests: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Subsystem: "http", Name: "requests_total", Help: "Total application HTTP requests."}, []string{"method", "route", "status_class"}),
- httpDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Subsystem: "http", Name: "request_duration_seconds", Help: "Application HTTP request duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"method", "route", "status_class"}),
- rateLimited: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "rate_limited_total", Help: "Total requests rejected by local rate limits."}, []string{"scope", "operation"}),
- jobs: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "jobs_total", Help: "Total completed background job attempts."}, []string{"type", "result"}),
- jobDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Name: "job_duration_seconds", Help: "Background job attempt duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"type", "result"}),
- jobQueueDepth: prometheus.NewGaugeVec(prometheus.GaugeOpts{Namespace: "mooncode", Name: "job_queue_depth", Help: "Current background job queue depth."}, []string{"type", "status"}),
- channelRuntime: prometheus.NewGaugeVec(prometheus.GaugeOpts{Namespace: "mooncode", Name: "channel_runtime", Help: "Current channel runtimes by state."}, []string{"type", "state"}),
- channelReconnects: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "channel_reconnects_total", Help: "Total channel runtime reconnect outcomes."}, []string{"type", "result"}),
- channelMessages: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "channel_messages_total", Help: "Total normalized channel messages."}, []string{"type", "result"}),
- gitOperations: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "git_operations_total", Help: "Total Git operations."}, []string{"operation", "provider", "result"}),
- gitDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Name: "git_operation_duration_seconds", Help: "Git operation duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"operation", "provider", "result"}),
- storageOperations: prometheus.NewCounterVec(prometheus.CounterOpts{Namespace: "mooncode", Name: "storage_operations_total", Help: "Total binary storage operations."}, []string{"backend", "operation", "result"}),
- storageDuration: prometheus.NewHistogramVec(prometheus.HistogramOpts{Namespace: "mooncode", Name: "storage_operation_duration_seconds", Help: "Binary storage operation duration in seconds.", Buckets: prometheus.DefBuckets}, []string{"backend", "operation", "result"}),
- outboxBacklog: prometheus.NewGauge(prometheus.GaugeOpts{Namespace: "mooncode", Name: "outbox_backlog", Help: "Current unpublished outbox event count."}),
- }
- registerer.MustRegister(
- metrics.requests, metrics.httpDuration, metrics.rateLimited, metrics.jobs, metrics.jobDuration,
- metrics.jobQueueDepth, metrics.channelRuntime, metrics.channelReconnects, metrics.channelMessages,
- metrics.gitOperations, metrics.gitDuration, metrics.storageOperations, metrics.storageDuration,
- metrics.outboxBacklog,
- )
- return metrics
-}
-
-func (m *Metrics) ObserveRateLimited(scope, operation string) {
- m.rateLimited.WithLabelValues(oneOf(scope, "ip", "user", "workspace"), oneOf(operation, "read", "write")).Inc()
-}
-
-func (m *Metrics) Observe(method, route, statusClass string, seconds float64) {
- m.requests.WithLabelValues(method, route, statusClass).Inc()
- m.httpDuration.WithLabelValues(method, route, statusClass).Observe(seconds)
-}
-
-func (m *Metrics) ObserveJob(jobType, result string, seconds float64) {
- jobType = oneOf(jobType, "repository.sync")
- result = oneOf(result, "success", "retry", "failed", "lease_lost")
- m.jobs.WithLabelValues(jobType, result).Inc()
- m.jobDuration.WithLabelValues(jobType, result).Observe(seconds)
-}
-
-func (m *Metrics) SetJobQueueDepth(jobType, status string, depth float64) {
- m.jobQueueDepth.WithLabelValues(oneOf(jobType, "repository.sync"), oneOf(status, "queued", "running")).Set(depth)
-}
-
-func (m *Metrics) AddChannelRuntime(channelType, state string, delta float64) {
- m.channelRuntime.WithLabelValues(oneOf(channelType, "feishu", "dingtalk"), oneOf(state, "running", "retry_wait", "misconfigured")).Add(delta)
-}
-
-func (m *Metrics) ObserveChannelReconnect(channelType, result string) {
- m.channelReconnects.WithLabelValues(oneOf(channelType, "feishu", "dingtalk"), oneOf(result, "connected", "failed", "disconnected")).Inc()
-}
-
-func (m *Metrics) ObserveChannelMessage(channelType, result string) {
- m.channelMessages.WithLabelValues(oneOf(channelType, "feishu", "dingtalk"), oneOf(result, "accepted", "duplicate", "rejected", "failed")).Inc()
-}
-
-func (m *Metrics) ObserveGit(operation, provider, result string, seconds float64) {
- labels := []string{oneOf(operation, "probe", "fetch"), oneOf(provider, "github", "gitlab", "generic"), oneOf(result, "success", "error", "cancelled")}
- m.gitOperations.WithLabelValues(labels...).Inc()
- m.gitDuration.WithLabelValues(labels...).Observe(seconds)
-}
-
-func (m *Metrics) ObserveStorage(backend, operation, result string, seconds float64) {
- labels := []string{oneOf(backend, "s3", "filesystem"), oneOf(operation, "put", "open", "stat", "delete"), oneOf(result, "success", "not_found", "conflict", "error", "cancelled")}
- m.storageOperations.WithLabelValues(labels...).Inc()
- m.storageDuration.WithLabelValues(labels...).Observe(seconds)
-}
-
-func (m *Metrics) SetOutboxBacklog(depth float64) { m.outboxBacklog.Set(depth) }
-
-func oneOf(value string, allowed ...string) string {
- for _, candidate := range allowed {
- if value == candidate {
- return value
- }
- }
- return "other"
-}
diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go
deleted file mode 100644
index 5c9fed9..0000000
--- a/internal/observability/metrics_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package observability
-
-import (
- "testing"
-
- "github.com/prometheus/client_golang/prometheus"
- "github.com/stretchr/testify/require"
-)
-
-func TestMetricLabelsBoundUnexpectedValues(t *testing.T) {
- registry := prometheus.NewRegistry()
- metrics := NewMetrics(registry)
- dynamicID := "019fa817-7784-7958-bd49-cc551ebc8173"
- metrics.ObserveGit("fetch", dynamicID, "success", 0.01)
- metrics.ObserveChannelMessage(dynamicID, "accepted")
- metrics.ObserveStorage(dynamicID, dynamicID, "error", 0.01)
-
- families, err := registry.Gather()
- require.NoError(t, err)
- for _, family := range families {
- for _, metric := range family.Metric {
- for _, label := range metric.Label {
- require.NotEqual(t, dynamicID, label.GetValue(), family.GetName())
- }
- }
- }
-}
-
-func TestOneOfNeverPassesThroughUnknownValue(t *testing.T) {
- require.Equal(t, "ready", oneOf("ready", "ready", "failed"))
- require.Equal(t, "other", oneOf("tenant-controlled", "ready", "failed"))
-}
diff --git a/internal/observability/providers.go b/internal/observability/providers.go
deleted file mode 100644
index 664d949..0000000
--- a/internal/observability/providers.go
+++ /dev/null
@@ -1,132 +0,0 @@
-package observability
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "strings"
- "time"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/prometheus/client_golang/prometheus"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/attribute"
- "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/sdk/resource"
- sdktrace "go.opentelemetry.io/otel/sdk/trace"
- "go.opentelemetry.io/otel/trace"
- "go.opentelemetry.io/otel/trace/noop"
- "go.uber.org/dig"
-)
-
-const ServiceName = "mooncode-backend"
-
-type ServiceInfo struct {
- Version string
- Commit string
- Environment string
- InstanceID string
-}
-
-type Shutdowner interface {
- Shutdown(context.Context) error
-}
-
-type Dependencies struct {
- dig.Out
-
- Logger zerolog.Logger
- Registry *prometheus.Registry
- Metrics *Metrics
- TracerProvider trace.TracerProvider
- Propagator propagation.TextMapPropagator
- Shutdowner Shutdowner
-}
-
-func NewDependencies(cfg config.Config, info ServiceInfo) (Dependencies, error) {
- logger, err := newLogger(cfg.Log, info)
- if err != nil {
- return Dependencies{}, err
- }
-
- registry := prometheus.NewRegistry()
- metrics := NewMetrics(registry)
- // Baggage remains disabled until an explicit key allowlist is configured.
- propagator := propagation.NewCompositeTextMapPropagator(propagation.TraceContext{})
-
- tracerProvider, shutdowner, err := newTracerProvider(context.Background(), cfg.Observability.Tracing, info)
- if err != nil {
- return Dependencies{}, err
- }
-
- return Dependencies{
- Logger: logger,
- Registry: registry,
- Metrics: metrics,
- TracerProvider: tracerProvider,
- Propagator: propagator,
- Shutdowner: shutdowner,
- }, nil
-}
-
-func newLogger(cfg config.LogConfig, info ServiceInfo) (zerolog.Logger, error) {
- level, err := zerolog.ParseLevel(strings.ToLower(cfg.Level))
- if err != nil {
- return zerolog.Logger{}, fmt.Errorf("parse log level: %w", err)
- }
-
- var writer io.Writer = os.Stdout
- if cfg.Format == "console" {
- writer = zerolog.ConsoleWriter{Out: os.Stdout, TimeFormat: time.RFC3339}
- }
-
- logger := zerolog.New(writer).Level(level).With().
- Timestamp().
- Str("service_name", ServiceName).
- Str("service_version", info.Version).
- Str("service_commit", info.Commit).
- Str("service_instance_id", info.InstanceID).
- Str("deployment_environment", info.Environment).
- Logger()
- return logger, nil
-}
-
-func newTracerProvider(
- ctx context.Context,
- cfg config.TracingConfig,
- info ServiceInfo,
-) (trace.TracerProvider, Shutdowner, error) {
- if !cfg.Enabled {
- return noop.NewTracerProvider(), shutdownFunc(func(context.Context) error { return nil }), nil
- }
-
- exporter, err := otlptracehttp.New(ctx,
- otlptracehttp.WithEndpointURL(cfg.Endpoint),
- otlptracehttp.WithTimeout(cfg.ExporterTimeout),
- )
- if err != nil {
- return nil, nil, fmt.Errorf("create OTLP trace exporter: %w", err)
- }
-
- res := resource.NewWithAttributes("",
- attribute.String("service.name", ServiceName),
- attribute.String("service.version", info.Version),
- attribute.String("service.commit", info.Commit),
- attribute.String("service.instance.id", info.InstanceID),
- attribute.String("deployment.environment", info.Environment),
- )
- provider := sdktrace.NewTracerProvider(
- sdktrace.WithBatcher(exporter),
- sdktrace.WithResource(res),
- sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(cfg.SampleRatio))),
- )
- return provider, shutdownFunc(provider.Shutdown), nil
-}
-
-type shutdownFunc func(context.Context) error
-
-func (function shutdownFunc) Shutdown(ctx context.Context) error {
- return function(ctx)
-}
diff --git a/internal/outbox/dispatcher.go b/internal/outbox/dispatcher.go
deleted file mode 100644
index d02cb6d..0000000
--- a/internal/outbox/dispatcher.go
+++ /dev/null
@@ -1,152 +0,0 @@
-// Package outbox delivers committed domain events from PostgreSQL.
-package outbox
-
-import (
- "context"
- "encoding/json"
- "errors"
- "sync/atomic"
- "time"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/codes"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
-)
-
-type Publisher interface {
- Publish(ctx context.Context, event model.OutboxEvent) error
-}
-
-type PublisherFunc func(context.Context, model.OutboxEvent) error
-
-func (function PublisherFunc) Publish(ctx context.Context, event model.OutboxEvent) error {
- return function(ctx, event)
-}
-
-func NewLocalPublisher() Publisher {
- return PublisherFunc(func(context.Context, model.OutboxEvent) error { return nil })
-}
-
-type Observer interface{ SetOutboxBacklog(float64) }
-type Option func(*Dispatcher)
-
-func WithClock(clock func() time.Time) Option {
- return func(dispatcher *Dispatcher) {
- if clock != nil {
- dispatcher.clock = clock
- }
- }
-}
-
-type Dispatcher struct {
- store repository.OutboxStore
- publisher Publisher
- owner string
- cfg config.OutboxConfig
- logger zerolog.Logger
- observer Observer
- tracer trace.Tracer
- propagator propagation.TextMapPropagator
- clock func() time.Time
- ready atomic.Bool
-}
-
-func New(store repository.OutboxStore, publisher Publisher, owner string, cfg config.Config, logger zerolog.Logger, observer Observer, provider trace.TracerProvider, propagator propagation.TextMapPropagator, opts ...Option) (*Dispatcher, error) {
- if store == nil || publisher == nil || owner == "" || observer == nil || provider == nil || propagator == nil {
- return nil, errors.New("outbox dispatcher dependencies are required")
- }
- dispatcher := &Dispatcher{
- store: store, publisher: publisher, owner: owner, cfg: cfg.Outbox, logger: logger, observer: observer,
- tracer: provider.Tracer("github.com/mooncode-ai/mooncode/internal/outbox"), propagator: propagator, clock: time.Now,
- }
- for _, option := range opts {
- if option != nil {
- option(dispatcher)
- }
- }
- return dispatcher, nil
-}
-
-func (d *Dispatcher) Ready() bool { return d.ready.Load() }
-
-func (d *Dispatcher) Run(ctx context.Context) error {
- ticker := time.NewTicker(d.cfg.PollInterval)
- defer ticker.Stop()
- for {
- if _, err := d.Sweep(ctx); err != nil && !errors.Is(err, context.Canceled) {
- d.ready.Store(false)
- d.logger.Warn().Err(err).Str("operation", "outbox.sweep").Msg("outbox sweep failed")
- } else if ctx.Err() == nil {
- d.ready.Store(true)
- }
- select {
- case <-ctx.Done():
- d.ready.Store(false)
- return nil
- case <-ticker.C:
- }
- }
-}
-
-func (d *Dispatcher) Sweep(ctx context.Context) (int, error) {
- backlog, err := d.store.CountOutboxBacklog(ctx)
- if err != nil {
- return 0, err
- }
- d.observer.SetOutboxBacklog(float64(backlog))
- processed := 0
- for processed < d.cfg.BatchSize {
- now := d.clock().UTC()
- event, err := d.store.ClaimOutboxEvent(ctx, d.owner, now.Add(d.cfg.LeaseDuration))
- if errors.Is(err, repository.ErrNotFound) {
- return processed, nil
- }
- if err != nil {
- return processed, err
- }
- processed++
- if err := d.publish(ctx, event); err != nil {
- return processed, err
- }
- }
- return processed, nil
-}
-
-func (d *Dispatcher) publish(parent context.Context, event model.OutboxEvent) error {
- links := linksFromTraceContext(d.propagator, event.TraceContext)
- ctx, span := d.tracer.Start(parent, "outbox.publish", trace.WithSpanKind(trace.SpanKindConsumer), trace.WithLinks(links...))
- defer span.End()
- ctx, cancel := context.WithTimeout(ctx, d.cfg.PublishTimeout)
- err := d.publisher.Publish(ctx, event)
- cancel()
- if err == nil {
- if completeErr := d.store.CompleteOutboxEvent(parent, event); completeErr != nil {
- return completeErr
- }
- return nil
- }
- span.RecordError(err)
- span.SetStatus(codes.Error, "publish failed")
- delay := d.cfg.RetryBaseDelay * time.Duration(1< d.cfg.RetryMaxDelay {
- delay = d.cfg.RetryMaxDelay
- }
- return d.store.RetryOutboxEvent(parent, event, d.clock().UTC().Add(delay), "outbox.publish_failed", "Event publication failed")
-}
-
-func linksFromTraceContext(propagator propagation.TextMapPropagator, encoded json.RawMessage) []trace.Link {
- carrier := propagation.MapCarrier{}
- if len(encoded) == 0 || json.Unmarshal(encoded, &carrier) != nil {
- return nil
- }
- ctx := propagator.Extract(context.Background(), carrier)
- spanContext := trace.SpanContextFromContext(ctx)
- if !spanContext.IsValid() {
- return nil
- }
- return []trace.Link{{SpanContext: spanContext}}
-}
diff --git a/internal/outbox/dispatcher_test.go b/internal/outbox/dispatcher_test.go
deleted file mode 100644
index 95c02b4..0000000
--- a/internal/outbox/dispatcher_test.go
+++ /dev/null
@@ -1,85 +0,0 @@
-package outbox
-
-import (
- "context"
- "errors"
- "testing"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/rs/zerolog"
- "github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace/noop"
-)
-
-func TestDispatcherCompletesPublishedEvent(t *testing.T) {
- now := time.Unix(1000, 0)
- event := model.OutboxEvent{ID: uuid.New(), Type: "repository.synced", Attempt: 1}
- store := &fakeStore{events: []model.OutboxEvent{event}, backlog: 1}
- observer := &fakeObserver{}
- dispatcher := newTestDispatcher(t, store, NewLocalPublisher(), observer, now)
-
- processed, err := dispatcher.Sweep(context.Background())
- require.NoError(t, err)
- require.Equal(t, 1, processed)
- require.Equal(t, []uuid.UUID{event.ID}, store.completed)
- require.Equal(t, float64(1), observer.backlog)
-}
-
-func TestDispatcherRetriesPublisherFailure(t *testing.T) {
- now := time.Unix(1000, 0)
- event := model.OutboxEvent{ID: uuid.New(), Type: "im.message_received", Attempt: 3}
- store := &fakeStore{events: []model.OutboxEvent{event}, backlog: 1}
- publisher := PublisherFunc(func(context.Context, model.OutboxEvent) error { return errors.New("unavailable") })
- dispatcher := newTestDispatcher(t, store, publisher, &fakeObserver{}, now)
-
- processed, err := dispatcher.Sweep(context.Background())
- require.NoError(t, err)
- require.Equal(t, 1, processed)
- require.Equal(t, now.Add(4*time.Second).UTC(), store.retried[0])
-}
-
-func newTestDispatcher(t *testing.T, store *fakeStore, publisher Publisher, observer Observer, now time.Time) *Dispatcher {
- t.Helper()
- cfg := config.Config{Outbox: config.OutboxConfig{
- PollInterval: time.Second, BatchSize: 10, LeaseDuration: 30 * time.Second, PublishTimeout: 10 * time.Second,
- RetryBaseDelay: time.Second, RetryMaxDelay: time.Minute,
- }}
- dispatcher, err := New(store, publisher, "instance", cfg, zerolog.Nop(), observer, noop.NewTracerProvider(), propagation.TraceContext{}, WithClock(func() time.Time { return now }))
- require.NoError(t, err)
- return dispatcher
-}
-
-type fakeObserver struct{ backlog float64 }
-
-func (o *fakeObserver) SetOutboxBacklog(value float64) { o.backlog = value }
-
-type fakeStore struct {
- events []model.OutboxEvent
- completed []uuid.UUID
- retried []time.Time
- backlog int64
-}
-
-func (f *fakeStore) ClaimOutboxEvent(_ context.Context, owner string, leaseUntil time.Time) (model.OutboxEvent, error) {
- if len(f.events) == 0 {
- return model.OutboxEvent{}, repository.ErrNotFound
- }
- event := f.events[0]
- f.events = f.events[1:]
- event.LeaseOwner, event.LeaseUntil = owner, &leaseUntil
- return event, nil
-}
-func (f *fakeStore) CompleteOutboxEvent(_ context.Context, event model.OutboxEvent) error {
- f.completed = append(f.completed, event.ID)
- return nil
-}
-func (f *fakeStore) RetryOutboxEvent(_ context.Context, _ model.OutboxEvent, next time.Time, _, _ string) error {
- f.retried = append(f.retried, next)
- return nil
-}
-func (f *fakeStore) CountOutboxBacklog(context.Context) (int64, error) { return f.backlog, nil }
diff --git a/internal/platform/audit/audit_integration_test.go b/internal/platform/audit/audit_integration_test.go
new file mode 100644
index 0000000..2928069
--- /dev/null
+++ b/internal/platform/audit/audit_integration_test.go
@@ -0,0 +1,47 @@
+//go:build integration
+
+package audit
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+)
+
+func TestAuditEventRollsBackWithFailedTransaction(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ action := "integration.transaction_rollback." + uuid.NewString()
+ tx, err := pool.Begin(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ if err := Record(ctx, sqlc.New(tx), Event{Action: action, Resource: "integration_test"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tx.Exec(ctx, "SELECT 1/0"); err == nil {
+ t.Fatal("expected transaction to enter a failed state")
+ }
+ if err := tx.Commit(ctx); err == nil {
+ t.Fatal("expected failed transaction commit to fail")
+ }
+
+ var count int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_events WHERE action=$1`, action).Scan(&count); err != nil {
+ t.Fatal(err)
+ }
+ if count != 0 {
+ t.Fatalf("failed transaction left %d orphan audit events", count)
+ }
+}
diff --git a/internal/platform/audit/metadata.go b/internal/platform/audit/metadata.go
new file mode 100644
index 0000000..f5a860d
--- /dev/null
+++ b/internal/platform/audit/metadata.go
@@ -0,0 +1,79 @@
+package audit
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+var forbiddenMetadataKeys = map[string]struct{}{
+ "accesstoken": {},
+ "appsecret": {},
+ "authorization": {},
+ "ciphertext": {},
+ "clientsecret": {},
+ "credential": {},
+ "credentials": {},
+ "nonce": {},
+ "password": {},
+ "pat": {},
+ "rawresponse": {},
+ "refreshtoken": {},
+ "response": {},
+ "secret": {},
+ "token": {},
+}
+
+func marshalMetadata(metadata map[string]any) ([]byte, error) {
+ if metadata == nil {
+ return []byte("{}"), nil
+ }
+
+ encoded, err := json.Marshal(metadata)
+ if err != nil {
+ return nil, fmt.Errorf("marshal audit metadata: %w", err)
+ }
+
+ var decoded map[string]any
+ if err := json.Unmarshal(encoded, &decoded); err != nil {
+ return nil, fmt.Errorf("validate audit metadata: %w", err)
+ }
+ if err := rejectSensitiveKeys(decoded); err != nil {
+ return nil, err
+ }
+
+ return encoded, nil
+}
+
+func rejectSensitiveKeys(value any) error {
+ switch typed := value.(type) {
+ case map[string]any:
+ for key, child := range typed {
+ if _, forbidden := forbiddenMetadataKeys[normalizeKey(key)]; forbidden {
+ return fmt.Errorf("audit metadata key %q is sensitive", key)
+ }
+ if err := rejectSensitiveKeys(child); err != nil {
+ return err
+ }
+ }
+ case []any:
+ for _, child := range typed {
+ if err := rejectSensitiveKeys(child); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+func normalizeKey(key string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ return unicode.ToLower(r)
+ }
+
+ return -1
+ }, key)
+}
diff --git a/internal/platform/audit/model.go b/internal/platform/audit/model.go
new file mode 100644
index 0000000..8602fdc
--- /dev/null
+++ b/internal/platform/audit/model.go
@@ -0,0 +1,45 @@
+package audit
+
+import (
+ "github.com/google/uuid"
+)
+
+const (
+ ActionProviderConnectionCreated = "provider_connection.created"
+ ActionProviderCredentialRotated = "provider_connection.credential_rotated"
+ ActionProviderConnectionDefaultSet = "provider_connection.default_set"
+ ActionProviderConnectionRevoked = "provider_connection.revoked"
+ ActionRepositoryProvisionRequested = "repository.provision_requested"
+ ActionRepositorySourceUpdateRequested = "repository.source_update_requested"
+ ActionRepositoryRefreshRequested = "repository.refresh_requested"
+ ActionRepositoryDeletionRequested = "repository.deletion_requested"
+ ActionRepositoryOperationCancelled = "repository.operation_cancelled"
+ ActionRepositoryPurgeRequeued = "repository.purge_requeued"
+ ActionAnalysisCreated = "analysis.created"
+ ActionAnalysisProfileCreated = "analysis_profile.created"
+ ActionAnalysisProfileVersionCreated = "analysis_profile.version_created"
+ ActionAnalysisProfileArchived = "analysis_profile.archived"
+ ActionChannelCreated = "channel.created"
+ ActionChannelConfigurationUpdated = "channel.configuration_updated"
+ ActionChannelCredentialRotated = "channel.credential_rotated"
+ ActionChannelEnabled = "channel.enabled"
+ ActionChannelDisabled = "channel.disabled"
+ ActionChannelDeleted = "channel.deleted"
+)
+
+const (
+ ResourceProviderConnection = "provider_connection"
+ ResourceRepository = "repository"
+ ResourceAnalysisRun = "analysis_run"
+ ResourceAnalysisProfile = "analysis_profile"
+ ResourceChannel = "channel"
+)
+
+type Event struct {
+ WorkspaceID uuid.UUID
+ ActorUserID uuid.UUID
+ Action string
+ Resource string
+ ResourceID uuid.UUID
+ Metadata map[string]any
+}
diff --git a/internal/platform/audit/writer.go b/internal/platform/audit/writer.go
new file mode 100644
index 0000000..cd7d3e2
--- /dev/null
+++ b/internal/platform/audit/writer.go
@@ -0,0 +1,45 @@
+package audit
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/google/uuid"
+)
+
+type Writer interface {
+ CreateAuditEvent(context.Context, sqlc.CreateAuditEventParams) error
+}
+
+func Record(ctx context.Context, writer Writer, event Event) error {
+ if writer == nil {
+ return fmt.Errorf("audit writer is required")
+ }
+ if strings.TrimSpace(event.Action) == "" {
+ return fmt.Errorf("audit action is required")
+ }
+ if strings.TrimSpace(event.Resource) == "" {
+ return fmt.Errorf("audit resource type is required")
+ }
+
+ metadata, err := marshalMetadata(event.Metadata)
+ if err != nil {
+ return err
+ }
+
+ return writer.CreateAuditEvent(ctx, sqlc.CreateAuditEventParams{
+ ID: uuid.New(),
+ WorkspaceID: nullableUUID(event.WorkspaceID),
+ ActorUserID: nullableUUID(event.ActorUserID),
+ Action: event.Action,
+ ResourceType: event.Resource,
+ ResourceID: nullableUUID(event.ResourceID),
+ Metadata: metadata,
+ })
+}
+
+func nullableUUID(value uuid.UUID) uuid.NullUUID {
+ return uuid.NullUUID{UUID: value, Valid: value != uuid.Nil}
+}
diff --git a/internal/platform/audit/writer_test.go b/internal/platform/audit/writer_test.go
new file mode 100644
index 0000000..3ee442e
--- /dev/null
+++ b/internal/platform/audit/writer_test.go
@@ -0,0 +1,67 @@
+package audit
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/google/uuid"
+)
+
+type recordingWriter struct {
+ params sqlc.CreateAuditEventParams
+}
+
+func (w *recordingWriter) CreateAuditEvent(_ context.Context, params sqlc.CreateAuditEventParams) error {
+ w.params = params
+
+ return nil
+}
+
+func TestRecordPreservesAuditIdentityAndSafeMetadata(t *testing.T) {
+ workspaceID, actorID, resourceID := uuid.New(), uuid.New(), uuid.New()
+ writer := &recordingWriter{}
+
+ err := Record(context.Background(), writer, Event{
+ WorkspaceID: workspaceID,
+ ActorUserID: actorID,
+ Action: ActionRepositoryRefreshRequested,
+ Resource: ResourceRepository,
+ ResourceID: resourceID,
+ Metadata: map[string]any{"operationId": uuid.New(), "credentialVersion": int64(4)},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !writer.params.WorkspaceID.Valid || writer.params.WorkspaceID.UUID != workspaceID ||
+ !writer.params.ActorUserID.Valid || writer.params.ActorUserID.UUID != actorID ||
+ !writer.params.ResourceID.Valid || writer.params.ResourceID.UUID != resourceID {
+ t.Fatalf("unexpected audit identity: %+v", writer.params)
+ }
+ var metadata map[string]any
+ if err := json.Unmarshal(writer.params.Metadata, &metadata); err != nil {
+ t.Fatal(err)
+ }
+ if metadata["operationId"] == nil || metadata["credentialVersion"] != float64(4) {
+ t.Fatalf("unexpected metadata: %s", writer.params.Metadata)
+ }
+}
+
+func TestRecordRejectsSensitiveMetadataAtAnyDepth(t *testing.T) {
+ tests := []map[string]any{
+ {"token": "github_pat_secret"},
+ {"nested": map[string]any{"app_secret": "secret"}},
+ {"items": []any{map[string]any{"Authorization": "Bearer secret"}}},
+ {"payload": json.RawMessage(`{"clientSecret":"secret"}`)},
+ }
+ for _, metadata := range tests {
+ err := Record(context.Background(), &recordingWriter{}, Event{
+ Action: ActionProviderConnectionCreated, Resource: ResourceProviderConnection, Metadata: metadata,
+ })
+ if err == nil || !strings.Contains(err.Error(), "sensitive") {
+ t.Fatalf("Record(%v) error = %v, want sensitive metadata rejection", metadata, err)
+ }
+ }
+}
diff --git a/internal/platform/auth/actor.go b/internal/platform/auth/actor.go
new file mode 100644
index 0000000..51f4aaf
--- /dev/null
+++ b/internal/platform/auth/actor.go
@@ -0,0 +1,28 @@
+package auth
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+type Actor struct {
+ UserID uuid.UUID
+ Subject string
+ DisplayName string
+ Username string
+ Email string
+ Status string
+}
+
+type actorKey struct{}
+
+func WithActor(ctx context.Context, actor Actor) context.Context {
+ return context.WithValue(ctx, actorKey{}, actor)
+}
+
+func ActorFrom(ctx context.Context) (Actor, bool) {
+ actor, ok := ctx.Value(actorKey{}).(Actor)
+
+ return actor, ok
+}
diff --git a/internal/platform/auth/csrf.go b/internal/platform/auth/csrf.go
new file mode 100644
index 0000000..43eb5ac
--- /dev/null
+++ b/internal/platform/auth/csrf.go
@@ -0,0 +1,68 @@
+package auth
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+type CSRF struct {
+ key []byte
+}
+
+func NewCSRF(key []byte) *CSRF {
+ return &CSRF{key: append([]byte(nil), key...)}
+}
+
+func (c *CSRF) Issue(actor Actor) (string, error) {
+ nonce := make([]byte, 24)
+ if _, err := rand.Read(nonce); err != nil {
+ return "", err
+ }
+ payload := base64.RawURLEncoding.EncodeToString(nonce)
+ signature := c.sign(actor.Subject, payload)
+
+ return payload + "." + base64.RawURLEncoding.EncodeToString(signature), nil
+}
+
+func (c *CSRF) Middleware() gin.HandlerFunc {
+ return func(ctx *gin.Context) {
+ if ctx.Request.Method == http.MethodGet || ctx.Request.Method == http.MethodHead || ctx.Request.Method == http.MethodOptions {
+ ctx.Next()
+ return
+ }
+ actor, ok := ActorFrom(ctx.Request.Context())
+ if !ok || !c.Valid(actor, ctx.GetHeader("X-CSRF-Token")) {
+ ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": gin.H{"code": "csrf.invalid", "message": "CSRF token is missing or invalid", "requestId": RequestID(ctx)}})
+ return
+ }
+ ctx.Next()
+ }
+}
+
+func (c *CSRF) Valid(actor Actor, token string) bool {
+ payload, encodedSignature, ok := strings.Cut(token, ".")
+ if !ok || payload == "" {
+ return false
+ }
+ signature, err := base64.RawURLEncoding.DecodeString(encodedSignature)
+ if err != nil {
+ return false
+ }
+
+ return hmac.Equal(signature, c.sign(actor.Subject, payload))
+}
+
+func (c *CSRF) sign(subject, payload string) []byte {
+ mac := hmac.New(sha256.New, c.key)
+ _, _ = mac.Write([]byte(subject))
+ _, _ = mac.Write([]byte{0})
+ _, _ = mac.Write([]byte(payload))
+
+ return mac.Sum(nil)
+}
diff --git a/internal/platform/auth/headers.go b/internal/platform/auth/headers.go
new file mode 100644
index 0000000..04382ab
--- /dev/null
+++ b/internal/platform/auth/headers.go
@@ -0,0 +1,34 @@
+package auth
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+)
+
+type Identity struct {
+ Subject string
+ DisplayName string
+ Username string
+ Email string
+}
+
+func IdentityFromHeaders(header http.Header) (Identity, error) {
+ identity := Identity{
+ Subject: strings.TrimSpace(header.Get("Remote-Sub")),
+ DisplayName: strings.TrimSpace(header.Get("Remote-Name")),
+ Username: strings.TrimSpace(header.Get("Remote-User")),
+ Email: strings.ToLower(strings.TrimSpace(header.Get("Remote-Email"))),
+ }
+ if identity.Subject == "" {
+ return Identity{}, errors.New("Remote-Sub is required")
+ }
+ if identity.Username == "" {
+ identity.Username = identity.Subject
+ }
+ if identity.DisplayName == "" {
+ identity.DisplayName = identity.Username
+ }
+
+ return identity, nil
+}
diff --git a/internal/platform/auth/headers_test.go b/internal/platform/auth/headers_test.go
new file mode 100644
index 0000000..67dde68
--- /dev/null
+++ b/internal/platform/auth/headers_test.go
@@ -0,0 +1,27 @@
+package auth
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestIdentityRequiresStableSubject(t *testing.T) {
+ header := http.Header{"Remote-User": []string{"octocat"}}
+ if _, err := IdentityFromHeaders(header); err == nil {
+ t.Fatal("expected Remote-Sub to be required")
+ }
+}
+
+func TestCSRFBindsTokenToSubject(t *testing.T) {
+ guard := NewCSRF([]byte("01234567890123456789012345678901"))
+ token, err := guard.Issue(Actor{Subject: "github:1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !guard.Valid(Actor{Subject: "github:1"}, token) {
+ t.Fatal("token should be valid")
+ }
+ if guard.Valid(Actor{Subject: "github:2"}, token) {
+ t.Fatal("token must not transfer between actors")
+ }
+}
diff --git a/internal/platform/auth/middleware.go b/internal/platform/auth/middleware.go
new file mode 100644
index 0000000..4ee3368
--- /dev/null
+++ b/internal/platform/auth/middleware.go
@@ -0,0 +1,49 @@
+package auth
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+type Resolver interface {
+ Resolve(context.Context, Identity) (Actor, error)
+}
+
+func Require(resolver Resolver, trusted *TrustedProxies) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if !trusted.Contains(c.Request.RemoteAddr) {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "identity.untrusted_gateway", "message": "Authenticated identity must come from the configured gateway", "requestId": RequestID(c)}})
+ return
+ }
+
+ identity, err := IdentityFromHeaders(c.Request.Header)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "auth.required", "message": "Sign in with GitHub to continue", "requestId": RequestID(c)}})
+ return
+ }
+
+ actor, err := resolver.Resolve(c.Request.Context(), identity)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "auth.identity_failed", "message": "Unable to resolve authenticated identity", "requestId": RequestID(c)}})
+ return
+ }
+ if actor.Status == "suspended" || actor.Status == "deleted" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": gin.H{"code": "account." + actor.Status, "message": "This account is not available", "requestId": RequestID(c)}})
+ return
+ }
+
+ c.Request = c.Request.WithContext(WithActor(c.Request.Context(), actor))
+ c.Next()
+ }
+}
+
+const requestIDKey = "request_id"
+
+func RequestID(c *gin.Context) string {
+ value, _ := c.Get(requestIDKey)
+ requestID, _ := value.(string)
+
+ return requestID
+}
diff --git a/internal/platform/auth/proxy.go b/internal/platform/auth/proxy.go
new file mode 100644
index 0000000..151e6e2
--- /dev/null
+++ b/internal/platform/auth/proxy.go
@@ -0,0 +1,50 @@
+package auth
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+type TrustedProxies struct {
+ networks []*net.IPNet
+}
+
+func NewTrustedProxies(values []string) (*TrustedProxies, error) {
+ if len(values) == 0 {
+ return nil, fmt.Errorf("at least one trusted identity proxy CIDR is required")
+ }
+
+ trusted := &TrustedProxies{networks: make([]*net.IPNet, 0, len(values))}
+ for _, value := range values {
+ _, network, err := net.ParseCIDR(strings.TrimSpace(value))
+ if err != nil {
+ return nil, fmt.Errorf("parse trusted identity proxy CIDR %q: %w", value, err)
+ }
+ trusted.networks = append(trusted.networks, network)
+ }
+
+ return trusted, nil
+}
+
+func (t *TrustedProxies) Contains(remoteAddress string) bool {
+ if t == nil {
+ return false
+ }
+
+ host, _, err := net.SplitHostPort(remoteAddress)
+ if err != nil {
+ host = remoteAddress
+ }
+ ip := net.ParseIP(strings.Trim(host, "[]"))
+ if ip == nil {
+ return false
+ }
+ for _, network := range t.networks {
+ if network.Contains(ip) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/internal/platform/auth/proxy_test.go b/internal/platform/auth/proxy_test.go
new file mode 100644
index 0000000..70b9010
--- /dev/null
+++ b/internal/platform/auth/proxy_test.go
@@ -0,0 +1,29 @@
+package auth
+
+import "testing"
+
+func TestTrustedProxiesMatchesOnlyDirectPeer(t *testing.T) {
+ trusted, err := NewTrustedProxies([]string{"127.0.0.1/32", "172.30.0.2/32"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, address := range []string{"127.0.0.1:8080", "172.30.0.2:49152"} {
+ if !trusted.Contains(address) {
+ t.Fatalf("expected %q to be trusted", address)
+ }
+ }
+ for _, address := range []string{"172.30.0.3:49152", "invalid", ""} {
+ if trusted.Contains(address) {
+ t.Fatalf("expected %q to be rejected", address)
+ }
+ }
+}
+
+func TestTrustedProxiesRejectsEmptyAndInvalidConfiguration(t *testing.T) {
+ if _, err := NewTrustedProxies(nil); err == nil {
+ t.Fatal("expected empty trust configuration to fail")
+ }
+ if _, err := NewTrustedProxies([]string{"not-a-cidr"}); err == nil {
+ t.Fatal("expected invalid trust configuration to fail")
+ }
+}
diff --git a/internal/platform/auth/request_id.go b/internal/platform/auth/request_id.go
new file mode 100644
index 0000000..662337f
--- /dev/null
+++ b/internal/platform/auth/request_id.go
@@ -0,0 +1,20 @@
+package auth
+
+import (
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func RequestIDMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ requestID := strings.TrimSpace(c.GetHeader("X-Request-ID"))
+ if requestID == "" || len(requestID) > 128 {
+ requestID = uuid.NewString()
+ }
+ c.Set(requestIDKey, requestID)
+ c.Header("X-Request-ID", requestID)
+ c.Next()
+ }
+}
diff --git a/internal/platform/buildinfo/buildinfo.go b/internal/platform/buildinfo/buildinfo.go
new file mode 100644
index 0000000..aa53b22
--- /dev/null
+++ b/internal/platform/buildinfo/buildinfo.go
@@ -0,0 +1,17 @@
+package buildinfo
+
+var (
+ Version = "dev"
+ Commit = "unknown"
+ BuildTime = "unknown"
+)
+
+type Info struct {
+ Version string
+ Commit string
+ BuildTime string
+}
+
+func Current() Info {
+ return Info{Version: Version, Commit: Commit, BuildTime: BuildTime}
+}
diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go
new file mode 100644
index 0000000..12a5de2
--- /dev/null
+++ b/internal/platform/config/config.go
@@ -0,0 +1,235 @@
+package config
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/spf13/viper"
+)
+
+type Config struct {
+ HTTP HTTP
+ Database Database
+ Auth Auth
+ Secrets Secrets
+ Repository Repository
+ Hatchet Hatchet
+ Analysis Analysis
+ Metrics Metrics
+}
+
+type HTTP struct {
+ Address string
+ MaxBodyBytes int64
+ RequestTimeout time.Duration
+}
+
+type Database struct {
+ URL string
+ MigrateOnStart bool
+ MaxConnections int32
+}
+
+type Auth struct {
+ Issuer string
+ AppURL string
+ LogoutURL string
+ RegistrationMode string
+ TermsVersion string
+ PrivacyVersion string
+ TrustedProxyCIDRs []string
+ CSRFKey []byte
+}
+
+type Secrets struct {
+ Key []byte
+ KeyVersion int
+}
+
+type Repository struct {
+ Root string
+ WorktreeMaxAge time.Duration
+ MaxMirrorBytes int64
+ MaxPerWorkspace int64
+}
+
+type Hatchet struct {
+ Token string
+ Namespace string
+ Address string
+}
+
+type Analysis struct {
+ SCCPath string
+ Timeout time.Duration
+ MaxOutputBytes int64
+ MaxConcurrentPerWorkspace int64
+}
+
+type Metrics struct {
+ WorkerAddress string
+}
+
+func Load(path string) (Config, error) {
+ v := viper.New()
+ v.SetEnvPrefix("MOONCODE")
+ v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
+ v.AutomaticEnv()
+
+ v.SetDefault("http.address", ":8080")
+ v.SetDefault("http.max_body_bytes", 1048576)
+ v.SetDefault("http.request_timeout", "30s")
+ v.SetDefault("database.max_connections", 20)
+ v.SetDefault("database.migrate_on_start", true)
+ v.SetDefault("auth.issuer", "tinyauth")
+ v.SetDefault("auth.app_url", "http://mooncode.localhost:3100/app")
+ v.SetDefault("auth.logout_url", "/api/user/logout")
+ v.SetDefault("auth.registration_mode", "public")
+ v.SetDefault("auth.terms_version", "2026-07-30")
+ v.SetDefault("auth.privacy_version", "2026-07-30")
+ v.SetDefault("auth.trusted_proxy_cidrs", []string{"127.0.0.1/32", "::1/128"})
+ v.SetDefault("secrets.key_version", 1)
+ v.SetDefault("repository.root", "/var/lib/mooncode/repositories")
+ v.SetDefault("repository.worktree_max_age", "24h")
+ v.SetDefault("repository.max_mirror_bytes", 10737418240)
+ v.SetDefault("repository.max_per_workspace", 100)
+ v.SetDefault("hatchet.namespace", "mooncode")
+ v.SetDefault("analysis.scc_path", "scc")
+ v.SetDefault("analysis.timeout", "15m")
+ v.SetDefault("analysis.max_output_bytes", 16777216)
+ v.SetDefault("analysis.max_concurrent_per_workspace", 10)
+ v.SetDefault("metrics.worker_address", ":9090")
+
+ if path != "" {
+ v.SetConfigFile(path)
+ if err := v.ReadInConfig(); err != nil {
+ return Config{}, fmt.Errorf("read config: %w", err)
+ }
+ }
+
+ csrfKey, err := decodeKey(v.GetString("auth.csrf_key"), "auth.csrf_key")
+ if err != nil {
+ return Config{}, err
+ }
+ secretKey, err := decodeKey(v.GetString("secrets.key"), "secrets.key")
+ if err != nil {
+ return Config{}, err
+ }
+ databaseMaxConnections := v.GetInt64("database.max_connections")
+ if databaseMaxConnections <= 0 || databaseMaxConnections > 1<<31-1 {
+ return Config{}, errors.New("database.max_connections must be between 1 and 2147483647")
+ }
+
+ cfg := Config{
+ HTTP: HTTP{
+ Address: v.GetString("http.address"), MaxBodyBytes: v.GetInt64("http.max_body_bytes"),
+ RequestTimeout: v.GetDuration("http.request_timeout"),
+ },
+ Database: Database{
+ URL: v.GetString("database.url"), MigrateOnStart: v.GetBool("database.migrate_on_start"),
+ MaxConnections: int32(databaseMaxConnections),
+ },
+ Auth: Auth{
+ Issuer: v.GetString("auth.issuer"), AppURL: v.GetString("auth.app_url"),
+ LogoutURL: v.GetString("auth.logout_url"), RegistrationMode: v.GetString("auth.registration_mode"),
+ TermsVersion: v.GetString("auth.terms_version"), PrivacyVersion: v.GetString("auth.privacy_version"),
+ TrustedProxyCIDRs: stringSlice(v.GetStringSlice("auth.trusted_proxy_cidrs")), CSRFKey: csrfKey,
+ },
+ Secrets: Secrets{Key: secretKey, KeyVersion: v.GetInt("secrets.key_version")},
+ Repository: Repository{Root: v.GetString("repository.root"), WorktreeMaxAge: v.GetDuration("repository.worktree_max_age"), MaxMirrorBytes: v.GetInt64("repository.max_mirror_bytes"), MaxPerWorkspace: v.GetInt64("repository.max_per_workspace")},
+ Hatchet: Hatchet{Token: v.GetString("hatchet.token"), Namespace: v.GetString("hatchet.namespace"), Address: v.GetString("hatchet.address")},
+ Analysis: Analysis{
+ SCCPath: v.GetString("analysis.scc_path"), Timeout: v.GetDuration("analysis.timeout"),
+ MaxOutputBytes: v.GetInt64("analysis.max_output_bytes"), MaxConcurrentPerWorkspace: v.GetInt64("analysis.max_concurrent_per_workspace"),
+ },
+ Metrics: Metrics{WorkerAddress: v.GetString("metrics.worker_address")},
+ }
+
+ if cfg.Database.URL == "" {
+ return Config{}, errors.New("database.url is required")
+ }
+ if strings.TrimSpace(cfg.HTTP.Address) == "" {
+ return Config{}, errors.New("http.address must not be empty")
+ }
+ if cfg.HTTP.MaxBodyBytes <= 0 {
+ return Config{}, errors.New("http.max_body_bytes must be greater than zero")
+ }
+ if cfg.HTTP.RequestTimeout <= 0 {
+ return Config{}, errors.New("http.request_timeout must be greater than zero")
+ }
+ if cfg.Analysis.MaxOutputBytes <= 0 {
+ return Config{}, errors.New("analysis.max_output_bytes must be greater than zero")
+ }
+ if strings.TrimSpace(cfg.Repository.Root) == "" {
+ return Config{}, errors.New("repository.root must not be empty")
+ }
+ if cfg.Repository.WorktreeMaxAge <= 0 {
+ return Config{}, errors.New("repository.worktree_max_age must be greater than zero")
+ }
+ if cfg.Repository.MaxMirrorBytes <= 0 {
+ return Config{}, errors.New("repository.max_mirror_bytes must be greater than zero")
+ }
+ if cfg.Repository.MaxPerWorkspace <= 0 {
+ return Config{}, errors.New("repository.max_per_workspace must be greater than zero")
+ }
+ if cfg.Analysis.MaxConcurrentPerWorkspace <= 0 {
+ return Config{}, errors.New("analysis.max_concurrent_per_workspace must be greater than zero")
+ }
+ if strings.TrimSpace(cfg.Analysis.SCCPath) == "" {
+ return Config{}, errors.New("analysis.scc_path must not be empty")
+ }
+ if cfg.Analysis.Timeout <= 0 {
+ return Config{}, errors.New("analysis.timeout must be greater than zero")
+ }
+ if cfg.Auth.RegistrationMode != "disabled" && cfg.Auth.RegistrationMode != "invite_only" && cfg.Auth.RegistrationMode != "public" {
+ return Config{}, errors.New("auth.registration_mode must be disabled, invite_only, or public")
+ }
+ if strings.TrimSpace(cfg.Metrics.WorkerAddress) == "" {
+ return Config{}, errors.New("metrics.worker_address must not be empty")
+ }
+ if len(cfg.Auth.CSRFKey) < 32 {
+ return Config{}, errors.New("auth.csrf_key must decode to at least 32 bytes")
+ }
+ if len(cfg.Auth.TrustedProxyCIDRs) == 0 {
+ return Config{}, errors.New("auth.trusted_proxy_cidrs must not be empty")
+ }
+ for _, value := range cfg.Auth.TrustedProxyCIDRs {
+ if _, _, err := net.ParseCIDR(value); err != nil {
+ return Config{}, fmt.Errorf("auth.trusted_proxy_cidrs contains invalid CIDR %q: %w", value, err)
+ }
+ }
+ if len(cfg.Secrets.Key) != 32 {
+ return Config{}, errors.New("secrets.key must decode to exactly 32 bytes")
+ }
+ if cfg.Secrets.KeyVersion <= 0 {
+ return Config{}, errors.New("secrets.key_version must be greater than zero")
+ }
+
+ return cfg, nil
+}
+
+func stringSlice(values []string) []string {
+ result := make([]string, 0, len(values))
+ for _, value := range values {
+ for _, item := range strings.Split(value, ",") {
+ if item = strings.TrimSpace(item); item != "" {
+ result = append(result, item)
+ }
+ }
+ }
+
+ return result
+}
+
+func decodeKey(value, name string) ([]byte, error) {
+ decoded, err := base64.StdEncoding.DecodeString(value)
+ if err != nil {
+ return nil, fmt.Errorf("%s must be base64: %w", name, err)
+ }
+
+ return decoded, nil
+}
diff --git a/internal/platform/config/config_test.go b/internal/platform/config/config_test.go
new file mode 100644
index 0000000..4fb69a0
--- /dev/null
+++ b/internal/platform/config/config_test.go
@@ -0,0 +1,143 @@
+package config
+
+import (
+ "encoding/base64"
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadFromEnvironment(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode")
+ t.Setenv("MOONCODE_AUTH_APP_URL", "https://env.mooncode.example/app")
+ t.Setenv("MOONCODE_AUTH_TRUSTED_PROXY_CIDRS", "10.10.0.2/32,10.10.0.3/32")
+ t.Setenv("MOONCODE_AUTH_CSRF_KEY", key)
+ t.Setenv("MOONCODE_SECRETS_KEY", key)
+
+ cfg, err := Load("")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Repository.Root == "" || cfg.Repository.MaxMirrorBytes <= 0 || cfg.Repository.MaxPerWorkspace <= 0 || cfg.Analysis.MaxConcurrentPerWorkspace <= 0 || cfg.Metrics.WorkerAddress != ":9090" || cfg.Database.URL == "" || cfg.Auth.AppURL != "https://env.mooncode.example/app" {
+ t.Fatal("expected defaults and database URL")
+ }
+ if len(cfg.Auth.TrustedProxyCIDRs) != 2 || cfg.Auth.TrustedProxyCIDRs[0] != "10.10.0.2/32" {
+ t.Fatalf("trusted proxy CIDRs = %v", cfg.Auth.TrustedProxyCIDRs)
+ }
+ if cfg.Auth.LogoutURL != "/api/user/logout" {
+ t.Fatalf("auth logout URL = %q", cfg.Auth.LogoutURL)
+ }
+}
+
+func TestLoadRejectsEmptyWorkerMetricsAddress(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode")
+ t.Setenv("MOONCODE_AUTH_CSRF_KEY", key)
+ t.Setenv("MOONCODE_SECRETS_KEY", key)
+ t.Setenv("MOONCODE_METRICS_WORKER_ADDRESS", " ")
+
+ if _, err := Load(""); err == nil || err.Error() != "metrics.worker_address must not be empty" {
+ t.Fatalf("Load() error = %v", err)
+ }
+}
+
+func TestLoadRejectsNonPositiveWorkspaceQuotas(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ for variable, want := range map[string]string{
+ "MOONCODE_REPOSITORY_MAX_PER_WORKSPACE": "repository.max_per_workspace must be greater than zero",
+ "MOONCODE_ANALYSIS_MAX_CONCURRENT_PER_WORKSPACE": "analysis.max_concurrent_per_workspace must be greater than zero",
+ } {
+ t.Run(variable, func(t *testing.T) {
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode")
+ t.Setenv("MOONCODE_AUTH_CSRF_KEY", key)
+ t.Setenv("MOONCODE_SECRETS_KEY", key)
+ t.Setenv(variable, "0")
+
+ if _, err := Load(""); err == nil || err.Error() != want {
+ t.Fatalf("Load() error = %v", err)
+ }
+ })
+ }
+}
+
+func TestLoadRejectsInvalidOperationalLimits(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ tests := []struct {
+ name string
+ variable string
+ value string
+ want string
+ }{
+ {name: "database connections zero", variable: "MOONCODE_DATABASE_MAX_CONNECTIONS", value: "0", want: "database.max_connections must be between 1 and 2147483647"},
+ {name: "database connections overflow", variable: "MOONCODE_DATABASE_MAX_CONNECTIONS", value: "2147483648", want: "database.max_connections must be between 1 and 2147483647"},
+ {name: "analysis timeout", variable: "MOONCODE_ANALYSIS_TIMEOUT", value: "0s", want: "analysis.timeout must be greater than zero"},
+ {name: "analysis binary", variable: "MOONCODE_ANALYSIS_SCC_PATH", value: " ", want: "analysis.scc_path must not be empty"},
+ {name: "repository root", variable: "MOONCODE_REPOSITORY_ROOT", value: " ", want: "repository.root must not be empty"},
+ {name: "HTTP address", variable: "MOONCODE_HTTP_ADDRESS", value: " ", want: "http.address must not be empty"},
+ {name: "secret key version", variable: "MOONCODE_SECRETS_KEY_VERSION", value: "0", want: "secrets.key_version must be greater than zero"},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode")
+ t.Setenv("MOONCODE_AUTH_CSRF_KEY", key)
+ t.Setenv("MOONCODE_SECRETS_KEY", key)
+ t.Setenv(test.variable, test.value)
+
+ if _, err := Load(""); err == nil || err.Error() != test.want {
+ t.Fatalf("Load() error = %v, want %q", err, test.want)
+ }
+ })
+ }
+}
+
+func TestLoadAppURLFromFile(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ path := filepath.Join(t.TempDir(), "mooncode.yaml")
+ contents := fmt.Sprintf(`
+database:
+ url: postgres://mooncode:test@localhost/mooncode
+auth:
+ app_url: https://file.mooncode.example/app
+ csrf_key: %s
+secrets:
+ key: %s
+`, key, key)
+ if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("MOONCODE_AUTH_APP_URL", "")
+
+ cfg, err := Load(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.Auth.AppURL != "https://file.mooncode.example/app" {
+ t.Fatalf("auth app URL = %q, want file value", cfg.Auth.AppURL)
+ }
+}
+
+func TestLoadRejectsNonPositiveMirrorQuota(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode")
+ t.Setenv("MOONCODE_AUTH_CSRF_KEY", key)
+ t.Setenv("MOONCODE_SECRETS_KEY", key)
+ t.Setenv("MOONCODE_REPOSITORY_MAX_MIRROR_BYTES", "0")
+
+ if _, err := Load(""); err == nil || err.Error() != "repository.max_mirror_bytes must be greater than zero" {
+ t.Fatalf("Load() error = %v", err)
+ }
+}
+
+func TestLoadRejectsInvalidRegistrationMode(t *testing.T) {
+ key := base64.StdEncoding.EncodeToString(make([]byte, 32))
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://mooncode:test@localhost/mooncode")
+ t.Setenv("MOONCODE_AUTH_CSRF_KEY", key)
+ t.Setenv("MOONCODE_SECRETS_KEY", key)
+ t.Setenv("MOONCODE_AUTH_REGISTRATION_MODE", "sometimes")
+
+ if _, err := Load(""); err == nil || err.Error() != "auth.registration_mode must be disabled, invite_only, or public" {
+ t.Fatalf("Load() error = %v", err)
+ }
+}
diff --git a/internal/platform/database/database.go b/internal/platform/database/database.go
new file mode 100644
index 0000000..86f269b
--- /dev/null
+++ b/internal/platform/database/database.go
@@ -0,0 +1,27 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func Open(ctx context.Context, databaseURL string, maxConnections int32) (*pgxpool.Pool, error) {
+ config, err := pgxpool.ParseConfig(databaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse database URL: %w", err)
+ }
+ config.MaxConns = maxConnections
+
+ pool, err := pgxpool.NewWithConfig(ctx, config)
+ if err != nil {
+ return nil, fmt.Errorf("open database: %w", err)
+ }
+ if err := pool.Ping(ctx); err != nil {
+ pool.Close()
+ return nil, fmt.Errorf("ping database: %w", err)
+ }
+
+ return pool, nil
+}
diff --git a/internal/platform/database/migrate.go b/internal/platform/database/migrate.go
new file mode 100644
index 0000000..d3d709a
--- /dev/null
+++ b/internal/platform/database/migrate.go
@@ -0,0 +1,61 @@
+package database
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/fuchencong/mooncode/migrations"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
+ connection, err := pool.Acquire(ctx)
+ if err != nil {
+ return fmt.Errorf("acquire migration connection: %w", err)
+ }
+ defer connection.Release()
+ if _, err := connection.Exec(ctx, `SELECT pg_advisory_lock(681778115536235679)`); err != nil {
+ return fmt.Errorf("lock migrations: %w", err)
+ }
+ defer func() { _, _ = connection.Exec(context.Background(), `SELECT pg_advisory_unlock(681778115536235679)`) }()
+
+ if _, err := connection.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
+ return fmt.Errorf("create migration table: %w", err)
+ }
+
+ names, err := migrationNames()
+ if err != nil {
+ return err
+ }
+ for _, name := range names {
+ var applied bool
+ if err := connection.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&applied); err != nil {
+ return fmt.Errorf("check migration %s: %w", name, err)
+ }
+ if applied {
+ continue
+ }
+
+ body, err := migrations.Postgres.ReadFile("postgres/" + name)
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", name, err)
+ }
+ tx, err := connection.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin migration %s: %w", name, err)
+ }
+ if _, err := tx.Exec(ctx, string(body)); err != nil {
+ _ = tx.Rollback(ctx)
+ return fmt.Errorf("apply migration %s: %w", name, err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations (name) VALUES ($1)`, name); err != nil {
+ _ = tx.Rollback(ctx)
+ return fmt.Errorf("record migration %s: %w", name, err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit migration %s: %w", name, err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/platform/database/readiness.go b/internal/platform/database/readiness.go
new file mode 100644
index 0000000..5fb5039
--- /dev/null
+++ b/internal/platform/database/readiness.go
@@ -0,0 +1,69 @@
+package database
+
+import (
+ "context"
+ "fmt"
+ "io/fs"
+ "sort"
+ "strings"
+
+ "github.com/fuchencong/mooncode/migrations"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type readinessDatabase interface {
+ Ping(context.Context) error
+ QueryRow(context.Context, string, ...any) pgx.Row
+}
+
+type Readiness struct {
+ database readinessDatabase
+}
+
+func NewReadiness(pool *pgxpool.Pool) *Readiness {
+ return newReadiness(pool)
+}
+
+func newReadiness(database readinessDatabase) *Readiness {
+ return &Readiness{database: database}
+}
+
+func (r *Readiness) Check(ctx context.Context) error {
+ if err := r.database.Ping(ctx); err != nil {
+ return fmt.Errorf("ping database: %w", err)
+ }
+
+ names, err := migrationNames()
+ if err != nil {
+ return err
+ }
+ for _, name := range names {
+ var applied bool
+ if err := r.database.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&applied); err != nil {
+ return fmt.Errorf("check migration %s: %w", name, err)
+ }
+ if !applied {
+ return fmt.Errorf("migration %s is not applied", name)
+ }
+ }
+
+ return nil
+}
+
+func migrationNames() ([]string, error) {
+ entries, err := fs.ReadDir(migrations.Postgres, "postgres")
+ if err != nil {
+ return nil, fmt.Errorf("list migrations: %w", err)
+ }
+
+ names := make([]string, 0, len(entries))
+ for _, entry := range entries {
+ if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".up.sql") {
+ names = append(names, entry.Name())
+ }
+ }
+ sort.Strings(names)
+
+ return names, nil
+}
diff --git a/internal/platform/database/readiness_test.go b/internal/platform/database/readiness_test.go
new file mode 100644
index 0000000..1918830
--- /dev/null
+++ b/internal/platform/database/readiness_test.go
@@ -0,0 +1,69 @@
+package database
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/jackc/pgx/v5"
+)
+
+type readinessDatabaseStub struct {
+ pingError error
+ applied bool
+ rowError error
+}
+
+func (s readinessDatabaseStub) Ping(context.Context) error {
+ return s.pingError
+}
+
+func (s readinessDatabaseStub) QueryRow(context.Context, string, ...any) pgx.Row {
+ return readinessRow{applied: s.applied, err: s.rowError}
+}
+
+type readinessRow struct {
+ applied bool
+ err error
+}
+
+func (r readinessRow) Scan(destinations ...any) error {
+ if r.err != nil {
+ return r.err
+ }
+ *(destinations[0].(*bool)) = r.applied
+
+ return nil
+}
+
+func TestMigrationNamesContainOnlyOrderedUpMigrations(t *testing.T) {
+ names, err := migrationNames()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(names) == 0 || names[0] != "000001_initial.up.sql" {
+ t.Fatalf("unexpected migration names: %v", names)
+ }
+ for index := 1; index < len(names); index++ {
+ if names[index-1] >= names[index] {
+ t.Fatalf("migration names are not strictly ordered: %v", names)
+ }
+ }
+}
+
+func TestReadinessRequiresDatabaseAndAppliedMigrations(t *testing.T) {
+ databaseError := errors.New("database unavailable")
+ if err := newReadiness(readinessDatabaseStub{pingError: databaseError}).Check(context.Background()); !errors.Is(err, databaseError) {
+ t.Fatalf("ping error = %v, want wrapped database error", err)
+ }
+
+ err := newReadiness(readinessDatabaseStub{applied: false}).Check(context.Background())
+ if err == nil || !strings.Contains(err.Error(), "is not applied") {
+ t.Fatalf("missing migration error = %v", err)
+ }
+
+ if err := newReadiness(readinessDatabaseStub{applied: true}).Check(context.Background()); err != nil {
+ t.Fatalf("ready database failed: %v", err)
+ }
+}
diff --git a/internal/platform/fault/fault.go b/internal/platform/fault/fault.go
new file mode 100644
index 0000000..770303c
--- /dev/null
+++ b/internal/platform/fault/fault.go
@@ -0,0 +1,59 @@
+package fault
+
+import "errors"
+
+type Kind uint8
+
+const (
+ Invalid Kind = iota + 1
+ Forbidden
+ NotFound
+ Conflict
+ Unavailable
+)
+
+type Error struct {
+ kind Kind
+ code string
+ message string
+ cause error
+}
+
+func New(kind Kind, code, message string) error {
+ return &Error{kind: kind, code: code, message: message}
+}
+
+func Wrap(kind Kind, code, message string, cause error) error {
+ return &Error{kind: kind, code: code, message: message, cause: cause}
+}
+
+func (e *Error) Error() string {
+ if e.cause != nil {
+ return e.message + ": " + e.cause.Error()
+ }
+
+ return e.message
+}
+
+func (e *Error) Unwrap() error {
+ return e.cause
+}
+
+func (e *Error) Kind() Kind {
+ return e.kind
+}
+
+func (e *Error) Code() string {
+ return e.code
+}
+
+func (e *Error) PublicMessage() string {
+ return e.message
+}
+
+func From(err error) (*Error, bool) {
+ var result *Error
+ ok := errors.As(err, &result)
+
+ return result, ok
+}
diff --git a/internal/platform/fault/fault_test.go b/internal/platform/fault/fault_test.go
new file mode 100644
index 0000000..bbd04a2
--- /dev/null
+++ b/internal/platform/fault/fault_test.go
@@ -0,0 +1,18 @@
+package fault
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestWrapPreservesCauseAndPublicMessage(t *testing.T) {
+ cause := errors.New("private detail")
+ err := Wrap(Unavailable, "provider.unavailable", "Provider is unavailable", cause)
+ problem, ok := From(err)
+ if !ok || problem.Code() != "provider.unavailable" || problem.PublicMessage() != "Provider is unavailable" {
+ t.Fatalf("unexpected fault: %#v, %v", problem, ok)
+ }
+ if !errors.Is(err, cause) {
+ t.Fatal("wrapped cause was not preserved")
+ }
+}
diff --git a/internal/platform/hatchet/client.go b/internal/platform/hatchet/client.go
new file mode 100644
index 0000000..94e244f
--- /dev/null
+++ b/internal/platform/hatchet/client.go
@@ -0,0 +1,361 @@
+package hatchet
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "strconv"
+ "strings"
+ "time"
+
+ analysisworkflow "github.com/fuchencong/mooncode/internal/analysis/workflow"
+ channelworkflow "github.com/fuchencong/mooncode/internal/channel/workflow"
+ repositoryworkflow "github.com/fuchencong/mooncode/internal/repository/workflow"
+ retentionworkflow "github.com/fuchencong/mooncode/internal/retention/workflow"
+ "github.com/google/uuid"
+ legacyclient "github.com/hatchet-dev/hatchet/pkg/client" //nolint:staticcheck // Hatchet v0.98.9's new SDK constructor still exposes these configuration options.
+ "github.com/hatchet-dev/hatchet/pkg/client/rest"
+ "github.com/hatchet-dev/hatchet/pkg/client/types"
+ "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Hatchet v0.98.9's new SDK documents this type for non-retryable task errors.
+ hatchetsdk "github.com/hatchet-dev/hatchet/sdks/go"
+)
+
+type RepositoryInput struct {
+ RepositoryOperationID string `json:"repository_operation_id"`
+ RepositoryID string `json:"repository_id"`
+}
+
+type AnalysisInput struct {
+ AnalysisRunID string `json:"analysis_run_id"`
+ RepositoryID string `json:"repository_id"`
+}
+
+type NotificationInput struct {
+ NotificationID string `json:"notification_id"`
+ ChannelID string `json:"channel_id"`
+}
+
+type RetentionSchedulerInput struct{}
+
+type RetentionInput struct {
+ CleanupID string `json:"cleanup_id"`
+ WorkspaceID string `json:"workspace_id"`
+}
+
+type Result struct {
+ Completed bool `json:"completed"`
+}
+
+const (
+ taskRepositoryOperation = "repository-operation"
+ taskAnalysisRun = "analysis-run"
+ taskNotificationDelivery = "notification-delivery"
+ taskRetentionScheduler = "retention-scheduler"
+ taskRetentionCleanup = "retention-cleanup"
+
+ repositoryConcurrencyExpression = "input.repository_id"
+ channelConcurrencyExpression = "input.channel_id"
+ workspaceConcurrencyExpression = "input.workspace_id"
+
+ runConfirmationTimeout = 5 * time.Second
+ runConfirmationInterval = 100 * time.Millisecond
+ idempotencyTTL = 24 * time.Hour
+)
+
+type Client struct {
+ hatchet *hatchetsdk.Client
+ repository *hatchetsdk.StandaloneTask
+ analysis *hatchetsdk.StandaloneTask
+ notification *hatchetsdk.StandaloneTask
+ retentionScheduler *hatchetsdk.StandaloneTask
+ retention *hatchetsdk.StandaloneTask
+ runExists func(context.Context, uuid.UUID) error
+}
+
+type workflowRunner interface {
+ RunNoWait(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error)
+}
+
+func New(
+ token, address, namespace string,
+ repositoryRunner *repositoryworkflow.Runner,
+ analysisRunner *analysisworkflow.Runner,
+ notificationRunner *channelworkflow.Runner,
+ retentionScheduler *retentionworkflow.Scheduler,
+ retentionRunner *retentionworkflow.Runner,
+ options ...Option,
+) (*Client, error) {
+ hatchetClient, err := newClient(token, address, namespace)
+ if err != nil {
+ return nil, err
+ }
+
+ clientSettings := settings{metrics: discardTaskMetrics{}}
+ for _, option := range options {
+ option(&clientSettings)
+ }
+
+ strategy := types.GroupRoundRobin
+ maxRuns := int32(1)
+ repositoryTask := hatchetClient.NewStandaloneTask(
+ taskRepositoryOperation,
+ func(ctx hatchetsdk.Context, input RepositoryInput) (*Result, error) {
+ return observeTask(clientSettings.metrics, taskRepositoryOperation, func() (*Result, error) {
+ operationID, err := uuid.Parse(input.RepositoryOperationID)
+ if err != nil {
+ return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries.
+ }
+
+ err = repositoryRunner.Execute(ctx, operationID)
+ err = taskFailure(ctx.RetryCount(), 3, err, func(projectionContext context.Context, cause error) error {
+ return repositoryRunner.MarkFailed(projectionContext, operationID, cause)
+ })
+
+ return &Result{Completed: err == nil}, err
+ })
+ },
+ hatchetsdk.WithExecutionTimeout(30*time.Minute),
+ hatchetsdk.WithScheduleTimeout(10*time.Minute),
+ hatchetsdk.WithRetries(3),
+ hatchetsdk.WithRetryBackoff(2, 60),
+ hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: repositoryConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}),
+ workflowIdempotency("input.repository_operation_id"),
+ )
+ analysisTask := hatchetClient.NewStandaloneTask(
+ taskAnalysisRun,
+ func(ctx hatchetsdk.Context, input AnalysisInput) (*Result, error) {
+ return observeTask(clientSettings.metrics, taskAnalysisRun, func() (*Result, error) {
+ runID, err := uuid.Parse(input.AnalysisRunID)
+ if err != nil {
+ return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries.
+ }
+
+ err = analysisRunner.Execute(ctx, runID)
+ err = taskFailure(ctx.RetryCount(), 2, err, func(projectionContext context.Context, cause error) error {
+ return analysisRunner.MarkFailed(projectionContext, runID, cause)
+ })
+
+ return &Result{Completed: err == nil}, err
+ })
+ },
+ hatchetsdk.WithExecutionTimeout(20*time.Minute),
+ hatchetsdk.WithScheduleTimeout(10*time.Minute),
+ hatchetsdk.WithRetries(2),
+ hatchetsdk.WithRetryBackoff(2, 60),
+ hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: repositoryConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}),
+ workflowIdempotency("input.analysis_run_id"),
+ )
+ notificationTask := hatchetClient.NewStandaloneTask(
+ taskNotificationDelivery,
+ func(ctx hatchetsdk.Context, input NotificationInput) (*Result, error) {
+ return observeTask(clientSettings.metrics, taskNotificationDelivery, func() (*Result, error) {
+ notificationID, err := uuid.Parse(input.NotificationID)
+ if err != nil {
+ return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries.
+ }
+
+ err = notificationRunner.Execute(ctx, notificationID)
+ err = taskFailure(ctx.RetryCount(), 5, err, func(projectionContext context.Context, cause error) error {
+ return notificationRunner.MarkFailed(projectionContext, notificationID, cause)
+ })
+
+ return &Result{Completed: err == nil}, err
+ })
+ },
+ hatchetsdk.WithExecutionTimeout(2*time.Minute),
+ hatchetsdk.WithScheduleTimeout(10*time.Minute),
+ hatchetsdk.WithRetries(5),
+ hatchetsdk.WithRetryBackoff(2, 60),
+ hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: channelConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}),
+ workflowIdempotency("input.notification_id"),
+ )
+ retentionSchedulerTask := hatchetClient.NewStandaloneTask(
+ taskRetentionScheduler,
+ func(ctx hatchetsdk.Context, _ RetentionSchedulerInput) (*Result, error) {
+ return observeTask(clientSettings.metrics, taskRetentionScheduler, func() (*Result, error) {
+ err := retentionScheduler.Execute(ctx)
+
+ return &Result{Completed: err == nil}, err
+ })
+ },
+ hatchetsdk.WithWorkflowCron("0 * * * *"),
+ hatchetsdk.WithExecutionTimeout(5*time.Minute),
+ hatchetsdk.WithScheduleTimeout(10*time.Minute),
+ hatchetsdk.WithRetries(3),
+ hatchetsdk.WithRetryBackoff(2, 60),
+ )
+ retentionTask := hatchetClient.NewStandaloneTask(
+ taskRetentionCleanup,
+ func(ctx hatchetsdk.Context, input RetentionInput) (*Result, error) {
+ return observeTask(clientSettings.metrics, taskRetentionCleanup, func() (*Result, error) {
+ cleanupID, err := uuid.Parse(input.CleanupID)
+ if err != nil {
+ return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries.
+ }
+ if _, err = uuid.Parse(input.WorkspaceID); err != nil {
+ return nil, worker.NewNonRetryableError(err) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries.
+ }
+
+ err = retentionRunner.Execute(ctx, cleanupID)
+ err = taskFailure(ctx.RetryCount(), 5, err, func(projectionContext context.Context, cause error) error {
+ return retentionRunner.MarkFailed(projectionContext, cleanupID, cause)
+ })
+
+ return &Result{Completed: err == nil}, err
+ })
+ },
+ hatchetsdk.WithExecutionTimeout(30*time.Minute),
+ hatchetsdk.WithScheduleTimeout(10*time.Minute),
+ hatchetsdk.WithRetries(5),
+ hatchetsdk.WithRetryBackoff(2, 60),
+ hatchetsdk.WithWorkflowConcurrency(types.Concurrency{Expression: workspaceConcurrencyExpression, MaxRuns: &maxRuns, LimitStrategy: &strategy}),
+ workflowIdempotency("input.cleanup_id"),
+ )
+
+ return &Client{
+ hatchet: hatchetClient, repository: repositoryTask, analysis: analysisTask,
+ notification: notificationTask, retentionScheduler: retentionSchedulerTask, retention: retentionTask,
+ runExists: func(ctx context.Context, id uuid.UUID) error {
+ _, err := hatchetClient.Runs().GetDetails(ctx, id)
+
+ return err
+ },
+ }, nil
+}
+
+func newClient(token, address, namespace string) (*hatchetsdk.Client, error) {
+ if strings.TrimSpace(token) == "" {
+ return nil, errors.New("hatchet token is required")
+ }
+
+ host, portText, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("parse Hatchet address: %w", err)
+ }
+ port, err := strconv.Atoi(portText)
+ if err != nil || port <= 0 || port > 65535 {
+ return nil, errors.New("hatchet address has an invalid port")
+ }
+
+ clientOptions := []legacyclient.ClientOpt{ //nolint:staticcheck // Hatchet v0.98.9's new SDK constructor requires this option type.
+ legacyclient.WithToken(token), //nolint:staticcheck // The current SDK exposes its configuration through ClientOpt.
+ legacyclient.WithHostPort(host, port), //nolint:staticcheck // The current SDK exposes its configuration through ClientOpt.
+ }
+ if strings.TrimSpace(namespace) != "" {
+ clientOptions = append(clientOptions, legacyclient.WithNamespace(namespace)) //nolint:staticcheck // The current SDK exposes its configuration through ClientOpt.
+ }
+
+ return hatchetsdk.NewClient(clientOptions...)
+}
+
+func workflowIdempotency(expression string) hatchetsdk.WorkflowOption {
+ return hatchetsdk.WithWorkflowIdempotency(hatchetsdk.IdempotencyConfig{
+ Expression: expression,
+ TTL: idempotencyTTL,
+ Method: hatchetsdk.IdempotencyMethodTTL,
+ })
+}
+
+func (c *Client) DispatchRepository(ctx context.Context, operationID, repositoryID uuid.UUID) (string, error) {
+ return c.dispatch(ctx, c.repository, RepositoryInput{RepositoryOperationID: operationID.String(), RepositoryID: repositoryID.String()})
+}
+
+func (c *Client) DispatchAnalysis(ctx context.Context, runID, repositoryID uuid.UUID) (string, error) {
+ return c.dispatch(ctx, c.analysis, AnalysisInput{AnalysisRunID: runID.String(), RepositoryID: repositoryID.String()})
+}
+
+func (c *Client) DispatchNotification(ctx context.Context, notificationID, channelID uuid.UUID) (string, error) {
+ return c.dispatch(ctx, c.notification, NotificationInput{NotificationID: notificationID.String(), ChannelID: channelID.String()})
+}
+
+func (c *Client) DispatchRetention(ctx context.Context, cleanupID, workspaceID uuid.UUID) (string, error) {
+ return c.dispatch(ctx, c.retention, RetentionInput{CleanupID: cleanupID.String(), WorkspaceID: workspaceID.String()})
+}
+
+func (c *Client) dispatch(ctx context.Context, task workflowRunner, input any) (string, error) {
+ ref, err := task.RunNoWait(ctx, input)
+ var runID string
+ if err == nil {
+ if ref != nil {
+ runID = ref.RunId
+ }
+ } else if collision, ok := hatchetsdk.IsIdempotencyCollisionError(err); ok {
+ runID = collision.ExistingRunExternalId
+ } else {
+ return "", err
+ }
+ if runID == "" {
+ return "", errors.New("hatchet returned an empty workflow run ID")
+ }
+
+ if err := c.confirmRun(ctx, runID); err != nil {
+ return "", err
+ }
+
+ return runID, nil
+}
+
+func (c *Client) confirmRun(ctx context.Context, runID string) error {
+ id, err := uuid.Parse(runID)
+ if err != nil {
+ return fmt.Errorf("parse Hatchet workflow run ID: %w", err)
+ }
+
+ confirmationContext, cancel := context.WithTimeout(ctx, runConfirmationTimeout)
+ defer cancel()
+
+ var lastErr error
+ for {
+ if err = c.runExists(confirmationContext, id); err == nil {
+ return nil
+ }
+ lastErr = err
+
+ timer := time.NewTimer(runConfirmationInterval)
+ select {
+ case <-confirmationContext.Done():
+ timer.Stop()
+
+ return fmt.Errorf("confirm Hatchet workflow run %s: %w", runID, errors.Join(lastErr, confirmationContext.Err()))
+ case <-timer.C:
+ }
+ }
+}
+
+func (c *Client) CancelRepository(ctx context.Context, workflowID string) error {
+ return c.cancel(ctx, workflowID)
+}
+
+func (c *Client) CancelAnalysis(ctx context.Context, workflowID string) error {
+ return c.cancel(ctx, workflowID)
+}
+
+func (c *Client) CancelWorkflow(ctx context.Context, workflowID string) error {
+ return c.cancel(ctx, workflowID)
+}
+
+func (c *Client) cancel(ctx context.Context, workflowID string) error {
+ id, err := uuid.Parse(workflowID)
+ if err != nil {
+ return err
+ }
+
+ ids := []uuid.UUID{id}
+ _, err = c.hatchet.Runs().Cancel(ctx, rest.V1CancelTaskRequest{ExternalIds: &ids})
+
+ return err
+}
+
+func (c *Client) StartWorker(ctx context.Context, name string, slots int) error {
+ value, err := c.hatchet.NewWorker(
+ name,
+ hatchetsdk.WithSlots(slots),
+ hatchetsdk.WithWorkflows(c.repository, c.analysis, c.notification, c.retentionScheduler, c.retention),
+ )
+ if err != nil {
+ return err
+ }
+
+ return value.StartBlocking(ctx)
+}
diff --git a/internal/platform/hatchet/failure.go b/internal/platform/hatchet/failure.go
new file mode 100644
index 0000000..8d6d842
--- /dev/null
+++ b/internal/platform/hatchet/failure.go
@@ -0,0 +1,47 @@
+package hatchet
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Hatchet v0.98.9's new SDK documents this type for non-retryable task errors.
+ "github.com/jackc/pgx/v5"
+)
+
+const failureProjectionTimeout = 15 * time.Second
+
+func taskFailure(retryCount, maxRetries int, cause error, markFailed func(context.Context, error) error) error {
+ if cause == nil {
+ return nil
+ }
+ permanent := isPermanentTaskError(cause)
+ if permanent || retryCount >= maxRetries {
+ projectionContext, cancel := context.WithTimeout(context.Background(), failureProjectionTimeout)
+ err := markFailed(projectionContext, cause)
+ cancel()
+ if err != nil {
+ return errors.Join(cause, fmt.Errorf("project workflow failure: %w", err))
+ }
+ }
+ if permanent {
+ return worker.NewNonRetryableError(cause) //nolint:staticcheck // Required by Hatchet's new SDK to stop retries.
+ }
+
+ return cause
+}
+
+func isPermanentTaskError(err error) bool {
+ if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return false
+ }
+ if workflowbiz.IsPermanent(err) || errors.Is(err, pgx.ErrNoRows) {
+ return true
+ }
+ problem, ok := fault.From(err)
+
+ return ok && problem.Kind() != fault.Unavailable
+}
diff --git a/internal/platform/hatchet/failure_test.go b/internal/platform/hatchet/failure_test.go
new file mode 100644
index 0000000..8811c0e
--- /dev/null
+++ b/internal/platform/hatchet/failure_test.go
@@ -0,0 +1,50 @@
+package hatchet
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/hatchet-dev/hatchet/pkg/worker" //nolint:staticcheck // Verify the marker required by Hatchet's new SDK retry protocol.
+)
+
+func TestTaskFailureClassificationAndProjection(t *testing.T) {
+ permanentCause := workflowbiz.Permanent(errors.New("invalid ref"))
+ marked := 0
+ err := taskFailure(0, 3, permanentCause, func(ctx context.Context, _ error) error {
+ if _, ok := ctx.Deadline(); !ok {
+ t.Fatal("failure projection context has no deadline")
+ }
+ marked++
+
+ return nil
+ })
+ if !worker.IsNonRetryableError(err) || marked != 1 { //nolint:staticcheck // Verify Hatchet retry classification.
+ t.Fatalf("permanent task failure = (%v, marked %d)", err, marked)
+ }
+
+ marked = 0
+ temporaryCause := fault.New(fault.Unavailable, "provider.unavailable", "Provider unavailable")
+ err = taskFailure(0, 3, temporaryCause, func(context.Context, error) error { marked++; return nil })
+ if worker.IsNonRetryableError(err) || marked != 0 { //nolint:staticcheck // Verify Hatchet retry classification.
+ t.Fatalf("temporary task failure = (%v, marked %d)", err, marked)
+ }
+ err = taskFailure(3, 3, temporaryCause, func(context.Context, error) error { marked++; return nil })
+ if worker.IsNonRetryableError(err) || marked != 1 { //nolint:staticcheck // Verify Hatchet retry classification.
+ t.Fatalf("exhausted temporary task failure = (%v, marked %d)", err, marked)
+ }
+
+ if isPermanentTaskError(context.DeadlineExceeded) {
+ t.Fatal("context deadline was classified as permanent")
+ }
+}
+
+func TestTaskFailureRetriesProjectionFailure(t *testing.T) {
+ projectionError := errors.New("database unavailable")
+ err := taskFailure(0, 3, workflowbiz.Permanent(errors.New("invalid input")), func(context.Context, error) error { return projectionError })
+ if worker.IsNonRetryableError(err) || !errors.Is(err, projectionError) { //nolint:staticcheck // Verify Hatchet retry classification.
+ t.Fatalf("projection failure = %v, want retryable joined error", err)
+ }
+}
diff --git a/internal/platform/hatchet/options.go b/internal/platform/hatchet/options.go
new file mode 100644
index 0000000..d036633
--- /dev/null
+++ b/internal/platform/hatchet/options.go
@@ -0,0 +1,37 @@
+package hatchet
+
+import "time"
+
+type TaskMetrics interface {
+ ObserveTask(task, status string, duration time.Duration)
+}
+
+type Option func(*settings)
+
+type settings struct {
+ metrics TaskMetrics
+}
+
+func WithMetrics(metrics TaskMetrics) Option {
+ return func(value *settings) {
+ if metrics != nil {
+ value.metrics = metrics
+ }
+ }
+}
+
+type discardTaskMetrics struct{}
+
+func (discardTaskMetrics) ObserveTask(string, string, time.Duration) {}
+
+func observeTask[T any](metrics TaskMetrics, name string, task func() (*T, error)) (*T, error) {
+ started := time.Now()
+ result, err := task()
+ status := "succeeded"
+ if err != nil {
+ status = "failed"
+ }
+ metrics.ObserveTask(name, status, time.Since(started))
+
+ return result, err
+}
diff --git a/internal/platform/hatchet/options_test.go b/internal/platform/hatchet/options_test.go
new file mode 100644
index 0000000..847bec3
--- /dev/null
+++ b/internal/platform/hatchet/options_test.go
@@ -0,0 +1,129 @@
+package hatchet
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+ hatchetsdk "github.com/hatchet-dev/hatchet/sdks/go"
+)
+
+func TestConcurrencyExpressionsMatchSerializedInputKeys(t *testing.T) {
+ tests := []struct {
+ input any
+ jsonKey string
+ expression string
+ }{
+ {RepositoryInput{}, "repository_id", repositoryConcurrencyExpression},
+ {AnalysisInput{}, "repository_id", repositoryConcurrencyExpression},
+ {NotificationInput{}, "channel_id", channelConcurrencyExpression},
+ {RetentionInput{}, "workspace_id", workspaceConcurrencyExpression},
+ }
+ for _, test := range tests {
+ encoded, err := json.Marshal(test.input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var object map[string]any
+ if err := json.Unmarshal(encoded, &object); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := object[test.jsonKey]; !ok {
+ t.Fatalf("serialized input %T has no %q key", test.input, test.jsonKey)
+ }
+ if test.expression != "input."+test.jsonKey {
+ t.Fatalf("expression %q does not select serialized key %q", test.expression, test.jsonKey)
+ }
+ }
+}
+
+type taskMetrics struct {
+ task string
+ status string
+ duration time.Duration
+}
+
+func (m *taskMetrics) ObserveTask(task, status string, duration time.Duration) {
+ m.task, m.status, m.duration = task, status, duration
+}
+
+func TestObserveTaskRecordsFinalAttemptOutcome(t *testing.T) {
+ metrics := &taskMetrics{}
+ want := errors.New("task failed")
+ _, err := observeTask(metrics, taskRetentionCleanup, func() (*Result, error) {
+ return nil, want
+ })
+ if !errors.Is(err, want) {
+ t.Fatalf("observeTask() error = %v, want %v", err, want)
+ }
+ if metrics.task != taskRetentionCleanup || metrics.status != "failed" || metrics.duration <= 0 {
+ t.Fatalf("task metrics = %+v", metrics)
+ }
+}
+
+type workflowRunnerFunc func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error)
+
+func (f workflowRunnerFunc) RunNoWait(ctx context.Context, input any, options ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) {
+ return f(ctx, input, options...)
+}
+
+func TestDispatchConfirmsCreatedRun(t *testing.T) {
+ runID := uuid.New()
+ confirmed := uuid.Nil
+ client := &Client{runExists: func(_ context.Context, id uuid.UUID) error {
+ confirmed = id
+
+ return nil
+ }}
+ runner := workflowRunnerFunc(func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) {
+ return &hatchetsdk.WorkflowRunRef{RunId: runID.String()}, nil
+ })
+
+ got, err := client.dispatch(context.Background(), runner, struct{}{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != runID.String() || confirmed != runID {
+ t.Fatalf("dispatch run = %q, confirmed = %s", got, confirmed)
+ }
+}
+
+func TestDispatchRecoversIdempotentRun(t *testing.T) {
+ runID := uuid.New()
+ client := &Client{runExists: func(_ context.Context, id uuid.UUID) error {
+ if id != runID {
+ t.Fatalf("confirmed run = %s, want %s", id, runID)
+ }
+
+ return nil
+ }}
+ runner := workflowRunnerFunc(func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) {
+ return nil, &hatchetsdk.IdempotencyCollisionError{ExistingRunExternalId: runID.String()}
+ })
+
+ got, err := client.dispatch(context.Background(), runner, struct{}{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != runID.String() {
+ t.Fatalf("dispatch run = %q, want %q", got, runID)
+ }
+}
+
+func TestDispatchRejectsUnconfirmedRun(t *testing.T) {
+ runID := uuid.New()
+ want := errors.New("run not found")
+ client := &Client{runExists: func(context.Context, uuid.UUID) error { return want }}
+ runner := workflowRunnerFunc(func(context.Context, any, ...hatchetsdk.RunOptFunc) (*hatchetsdk.WorkflowRunRef, error) {
+ return &hatchetsdk.WorkflowRunRef{RunId: runID.String()}, nil
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
+ defer cancel()
+ if _, err := client.dispatch(ctx, runner, struct{}{}); !errors.Is(err, want) {
+ t.Fatalf("dispatch error = %v, want wrapped %v", err, want)
+ }
+}
diff --git a/internal/platform/httpserver/accesslog.go b/internal/platform/httpserver/accesslog.go
new file mode 100644
index 0000000..0667abd
--- /dev/null
+++ b/internal/platform/httpserver/accesslog.go
@@ -0,0 +1,30 @@
+package httpserver
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/gin-gonic/gin"
+)
+
+func accessLog(logger *slog.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ started := time.Now()
+ c.Next()
+
+ attributes := []any{
+ "request_id", auth.RequestID(c),
+ "method", c.Request.Method,
+ "route", c.FullPath(),
+ "status", c.Writer.Status(),
+ "response_bytes", c.Writer.Size(),
+ "duration_ms", time.Since(started).Milliseconds(),
+ "client_ip", c.ClientIP(),
+ }
+ if actor, ok := auth.ActorFrom(c.Request.Context()); ok {
+ attributes = append(attributes, "user_id", actor.UserID.String())
+ }
+ logger.InfoContext(c.Request.Context(), "http_request", attributes...)
+ }
+}
diff --git a/internal/platform/httpserver/analysis.go b/internal/platform/httpserver/analysis.go
new file mode 100644
index 0000000..c7461ae
--- /dev/null
+++ b/internal/platform/httpserver/analysis.go
@@ -0,0 +1,149 @@
+package httpserver
+
+import (
+ "net/http"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func (s *Server) registerAnalysis(api *gin.RouterGroup) {
+ api.GET("/workspaces/:workspaceID/repositories/:repositoryID/analysis-runs", s.listAnalysisRuns)
+ api.POST("/workspaces/:workspaceID/repositories/:repositoryID/analysis-runs", s.createAnalysisRun)
+ api.GET("/workspaces/:workspaceID/analysis-runs/:runID", s.getAnalysisRun)
+ api.GET("/workspaces/:workspaceID/reports/:reportID", s.getAnalysisReport)
+ api.POST("/workspaces/:workspaceID/analysis-runs/:runID/cancel", s.cancelAnalysisRun)
+ api.POST("/workspaces/:workspaceID/analysis-runs/:runID/retry", s.retryAnalysisRun)
+ api.GET("/workspaces/:workspaceID/analysis-profiles", s.listAnalysisProfiles)
+ api.POST("/workspaces/:workspaceID/analysis-profiles", s.createAnalysisProfile)
+ api.POST("/workspaces/:workspaceID/analysis-profiles/:profileID/versions", s.createAnalysisProfileVersion)
+ api.DELETE("/workspaces/:workspaceID/analysis-profiles/:profileID", s.archiveAnalysisProfile)
+}
+func (s *Server) getAnalysisReport(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ reportID, ok := parameterID(c, "reportID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.analyses.Report(c, a, workspaceID, reportID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) listAnalysisRuns(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.analyses.List(c, a, workspaceID, repositoryID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) createAnalysisRun(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ var input struct {
+ SnapshotID string `json:"snapshotId"`
+ ProfileID string `json:"profileId"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ snapshotID := uuid.Nil
+ if input.SnapshotID != "" {
+ var err error
+ snapshotID, err = uuid.Parse(input.SnapshotID)
+ if err != nil {
+ failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid snapshotId"))
+ return
+ }
+ }
+ profileID, err := uuid.Parse(input.ProfileID)
+ if err != nil {
+ failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid profileId"))
+ return
+ }
+ item, err := s.analyses.Create(c, a, workspaceID, repositoryID, analysis.CreateInput{SnapshotID: snapshotID, ProfileID: profileID})
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusAccepted, item)
+}
+func (s *Server) getAnalysisRun(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ runID, ok := parameterID(c, "runID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.analyses.Get(c, a, workspaceID, runID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) cancelAnalysisRun(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ runID, ok := parameterID(c, "runID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.analyses.Cancel(c, a, workspaceID, runID); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+func (s *Server) retryAnalysisRun(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ runID, ok := parameterID(c, "runID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.analyses.Retry(c, a, workspaceID, runID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusAccepted, item)
+}
diff --git a/internal/platform/httpserver/analysis_profiles.go b/internal/platform/httpserver/analysis_profiles.go
new file mode 100644
index 0000000..26b026a
--- /dev/null
+++ b/internal/platform/httpserver/analysis_profiles.go
@@ -0,0 +1,94 @@
+package httpserver
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+func (s *Server) listAnalysisProfiles(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ actor, _ := actor(c)
+ profiles, err := s.analyses.Profiles(c, actor, workspaceID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+
+ c.JSON(http.StatusOK, profiles)
+}
+
+func (s *Server) createAnalysisProfile(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Name string `json:"name"`
+ Definition json.RawMessage `json:"definition"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ actor, _ := actor(c)
+ profile, err := s.analyses.CreateProfile(c, actor, workspaceID, input.Name, input.Definition)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, profile)
+}
+
+func (s *Server) createAnalysisProfileVersion(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ profileID, ok := parameterID(c, "profileID")
+ if !ok {
+ return
+ }
+ var input struct {
+ CurrentVersion int32 `json:"currentVersion"`
+ Name string `json:"name"`
+ Definition json.RawMessage `json:"definition"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ actor, _ := actor(c)
+ profile, err := s.analyses.UpdateProfile(c, actor, workspaceID, profileID, input.CurrentVersion, input.Name, input.Definition)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, profile)
+}
+
+func (s *Server) archiveAnalysisProfile(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ profileID, ok := parameterID(c, "profileID")
+ if !ok {
+ return
+ }
+ actor, _ := actor(c)
+ if err := s.analyses.ArchiveProfile(c, actor, workspaceID, profileID); err != nil {
+ failure(c, err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
diff --git a/internal/platform/httpserver/channels.go b/internal/platform/httpserver/channels.go
new file mode 100644
index 0000000..6d0a25b
--- /dev/null
+++ b/internal/platform/httpserver/channels.go
@@ -0,0 +1,257 @@
+package httpserver
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func (s *Server) registerChannels(api *gin.RouterGroup) {
+ group := api.Group("/workspaces/:workspaceID")
+ group.GET("/overview", s.workspaceOverview)
+ group.GET("/channels", s.listChannels)
+ group.POST("/channels", s.createChannel)
+ group.PATCH("/channels/:channelID", s.updateChannel)
+ group.PUT("/channels/:channelID/credentials", s.rotateChannelCredential)
+ group.DELETE("/channels/:channelID", s.deleteChannel)
+ group.POST("/channels/:channelID/enable", s.enableChannel)
+ group.POST("/channels/:channelID/disable", s.disableChannel)
+ group.GET("/channels/:channelID/status", s.channelStatus)
+ group.GET("/conversations", s.listConversations)
+ group.GET("/messages", s.listMessages)
+ if s.commands != nil {
+ api.POST("/channel-identity-links/accept", s.acceptChannelIdentityLink)
+ }
+}
+
+func (s *Server) acceptChannelIdentityLink(c *gin.Context) {
+ var input struct {
+ Token string `json:"token"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.commands.Accept(c, a, input.Token); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+func (s *Server) workspaceOverview(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.channels.Overview(c, a, workspaceID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) listChannels(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.channels.List(c, a, workspaceID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) createChannel(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Values map[string]any `json:"values"`
+ Secrets map[string]string `json:"secrets"`
+ SenderAllowList []string `json:"senderAllowList"`
+ GroupPolicy map[string]any `json:"groupPolicy"`
+ NotificationEvents []string `json:"notificationEvents"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ config, _ := json.Marshal(gin.H{"values": input.Values, "senderAllowList": input.SenderAllowList, "groupPolicy": input.GroupPolicy})
+ credentials, _ := json.Marshal(input.Secrets)
+ a, _ := actor(c)
+ item, err := s.channels.Create(c, a, workspaceID, input.Type, input.Name, string(credentials), config, input.NotificationEvents)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusCreated, item)
+}
+func (s *Server) updateChannel(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ channelID, ok := parameterID(c, "channelID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Version int64 `json:"version"`
+ Name string `json:"name"`
+ Values map[string]any `json:"values"`
+ SenderAllowList []string `json:"senderAllowList"`
+ GroupPolicy map[string]any `json:"groupPolicy"`
+ NotificationEvents []string `json:"notificationEvents"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ config, _ := json.Marshal(gin.H{"values": input.Values, "senderAllowList": input.SenderAllowList, "groupPolicy": input.GroupPolicy})
+ a, _ := actor(c)
+ item, err := s.channels.Update(c, a, workspaceID, channelID, input.Name, config, input.NotificationEvents, input.Version)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) rotateChannelCredential(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ channelID, ok := parameterID(c, "channelID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Version int64 `json:"version"`
+ Secrets map[string]string `json:"secrets"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ credentials, _ := json.Marshal(input.Secrets)
+ a, _ := actor(c)
+ item, err := s.channels.RotateCredential(c, a, workspaceID, channelID, string(credentials), input.Version)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) deleteChannel(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ channelID, ok := parameterID(c, "channelID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.channels.Delete(c, a, workspaceID, channelID); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+func (s *Server) enableChannel(c *gin.Context) { s.setChannelEnabled(c, true) }
+func (s *Server) disableChannel(c *gin.Context) { s.setChannelEnabled(c, false) }
+func (s *Server) setChannelEnabled(c *gin.Context, enabled bool) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ channelID, ok := parameterID(c, "channelID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.channels.Enable(c, a, workspaceID, channelID, enabled)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) channelStatus(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ channelID, ok := parameterID(c, "channelID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.channels.Status(c, a, workspaceID, channelID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) listConversations(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.channels.Conversations(c, a, workspaceID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) listMessages(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ channelID, conversationID := uuid.NullUUID{}, uuid.NullUUID{}
+ if value := c.Query("channelId"); value != "" {
+ parsed, err := uuid.Parse(value)
+ if err != nil {
+ failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid channelId"))
+ return
+ }
+ channelID = uuid.NullUUID{UUID: parsed, Valid: true}
+ }
+ if value := c.Query("conversationId"); value != "" {
+ parsed, err := uuid.Parse(value)
+ if err != nil {
+ failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid conversationId"))
+ return
+ }
+ conversationID = uuid.NullUUID{UUID: parsed, Valid: true}
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.channels.Messages(c, a, workspaceID, channelID, conversationID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
diff --git a/internal/platform/httpserver/channels_test.go b/internal/platform/httpserver/channels_test.go
new file mode 100644
index 0000000..c8d9540
--- /dev/null
+++ b/internal/platform/httpserver/channels_test.go
@@ -0,0 +1,19 @@
+package httpserver
+
+import (
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestChannelIdentityLinkRouteIsNotRegisteredWithoutCommandService(t *testing.T) {
+ router := gin.New()
+ server := &Server{}
+ server.registerChannels(router.Group("/api/v1"))
+
+ for _, route := range router.Routes() {
+ if route.Path == "/api/v1/channel-identity-links/accept" {
+ t.Fatal("channel identity link route was registered without a command service")
+ }
+ }
+}
diff --git a/internal/platform/httpserver/helpers.go b/internal/platform/httpserver/helpers.go
new file mode 100644
index 0000000..f566a69
--- /dev/null
+++ b/internal/platform/httpserver/helpers.go
@@ -0,0 +1,85 @@
+package httpserver
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+)
+
+func actor(c *gin.Context) (auth.Actor, bool) { return auth.ActorFrom(c.Request.Context()) }
+func parameterID(c *gin.Context, name string) (uuid.UUID, bool) {
+ value, err := uuid.Parse(c.Param(name))
+ if err != nil {
+ failure(c, fault.New(fault.Invalid, "request.parameter_invalid", "Invalid "+name))
+ return uuid.Nil, false
+ }
+ return value, true
+}
+func bind(c *gin.Context, value any) bool {
+ if err := c.ShouldBindJSON(value); err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ failure(c, fault.New(fault.Invalid, "request.body_too_large", "Request body exceeds the allowed size"))
+ return false
+ }
+ failure(c, fault.New(fault.Invalid, "request.body_invalid", "Request body must be valid JSON"))
+ return false
+ }
+ return true
+}
+
+func pageRequest(c *gin.Context) (pagination.Request, bool) {
+ request, err := pagination.Parse(c.Query("cursor"), c.Query("limit"))
+ if err != nil {
+ failure(c, fault.Wrap(fault.Invalid, "request.page_invalid", "Page cursor or limit is invalid", err))
+
+ return pagination.Request{}, false
+ }
+
+ return request, true
+}
+
+func failure(c *gin.Context, err error) {
+ status, code, message := http.StatusInternalServerError, "internal.error", "MoonCode could not complete the request"
+ problem, known := fault.From(err)
+ if known {
+ code, message = problem.Code(), problem.PublicMessage()
+ switch problem.Kind() {
+ case fault.Invalid:
+ status = http.StatusBadRequest
+ if code == "request.body_too_large" {
+ status = http.StatusRequestEntityTooLarge
+ }
+ case fault.Forbidden:
+ status = http.StatusForbidden
+ case fault.NotFound:
+ status = http.StatusNotFound
+ case fault.Conflict:
+ status = http.StatusConflict
+ case fault.Unavailable:
+ status = http.StatusServiceUnavailable
+ }
+ } else {
+ var postgres *pgconn.PgError
+ switch {
+ case errors.Is(err, context.DeadlineExceeded):
+ status, code, message = http.StatusGatewayTimeout, "request.timeout", "Request timed out"
+ case errors.Is(err, identity.ErrForbidden):
+ status, code, message = http.StatusForbidden, "workspace.forbidden", "Workspace permission denied"
+ case errors.Is(err, pgx.ErrNoRows):
+ status, code, message = http.StatusNotFound, "resource.not_found", "Resource not found"
+ case errors.As(err, &postgres) && postgres.Code == "23505":
+ status, code, message = http.StatusConflict, "resource.conflict", "Resource already exists"
+ }
+ }
+ c.JSON(status, gin.H{"error": gin.H{"code": code, "message": message, "requestId": auth.RequestID(c)}})
+}
diff --git a/internal/platform/httpserver/identity.go b/internal/platform/httpserver/identity.go
new file mode 100644
index 0000000..8b0a45c
--- /dev/null
+++ b/internal/platform/httpserver/identity.go
@@ -0,0 +1,213 @@
+package httpserver
+
+import (
+ "net/http"
+
+ biz "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func (s *Server) registerIdentity(api, active *gin.RouterGroup) {
+ api.GET("/csrf", s.issueCSRF)
+ api.GET("/session", s.session)
+ api.POST("/onboarding/complete", s.completeOnboarding)
+ api.POST("/invitations/accept", s.acceptInvitation)
+ active.POST("/workspaces", s.createWorkspace)
+ active.PATCH("/workspaces/:workspaceID", s.updateWorkspace)
+ active.GET("/workspaces/:workspaceID/members", s.listMembers)
+ active.DELETE("/workspaces/:workspaceID/members/:userID", s.removeMember)
+ active.GET("/workspaces/:workspaceID/invitations", s.listInvitations)
+ active.POST("/workspaces/:workspaceID/invitations", s.createInvitation)
+ active.DELETE("/workspaces/:workspaceID/invitations/:invitationID", s.revokeInvitation)
+}
+func (s *Server) issueCSRF(c *gin.Context) {
+ a, _ := actor(c)
+ token, err := s.csrf.Issue(a)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"token": token})
+}
+func (s *Server) session(c *gin.Context) {
+ a, _ := actor(c)
+ user, workspaces, err := s.identity.Session(c, a)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"user": user, "workspaces": workspaces, "auth": gin.H{"logoutUrl": s.config.LogoutURL}, "registration": gin.H{"mode": s.config.RegistrationMode, "termsVersion": s.config.TermsVersion, "privacyVersion": s.config.PrivacyVersion}})
+}
+func (s *Server) completeOnboarding(c *gin.Context) {
+ var input struct {
+ AcceptTerms bool `json:"acceptTerms"`
+ AcceptPrivacy bool `json:"acceptPrivacy"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ if !input.AcceptTerms || !input.AcceptPrivacy {
+ failure(c, fault.New(fault.Invalid, "account.terms_required", "Terms and privacy policy must be accepted"))
+ return
+ }
+ a, _ := actor(c)
+ if s.config.RegistrationMode != "public" {
+ failure(c, fault.New(fault.Forbidden, "account.registration_closed", "Registration is not public"))
+ return
+ }
+ if err := s.identity.CompleteOnboarding(c, a); err != nil {
+ failure(c, err)
+ return
+ }
+ s.session(c)
+}
+func (s *Server) acceptInvitation(c *gin.Context) {
+ var input struct {
+ Token string `json:"token"`
+ AcceptTerms bool `json:"acceptTerms"`
+ AcceptPrivacy bool `json:"acceptPrivacy"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ if !input.AcceptTerms || !input.AcceptPrivacy {
+ failure(c, fault.New(fault.Invalid, "account.terms_required", "Terms and privacy policy must be accepted"))
+ return
+ }
+ a, _ := actor(c)
+ if s.config.RegistrationMode == "disabled" && a.Status != "active" {
+ failure(c, fault.New(fault.Forbidden, "account.registration_closed", "Registration is disabled"))
+ return
+ }
+ if err := s.identity.AcceptInvitation(c, a, input.Token); err != nil {
+ failure(c, err)
+ return
+ }
+ s.session(c)
+}
+func (s *Server) createWorkspace(c *gin.Context) {
+ var input struct {
+ Name string `json:"name"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.identity.CreateWorkspace(c, a, input.Name)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusCreated, item)
+}
+func (s *Server) updateWorkspace(c *gin.Context) {
+ id, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Name *string `json:"name"`
+ ReportRetentionDays *int32 `json:"reportRetentionDays"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.identity.UpdateWorkspace(c, a, id, biz.WorkspaceUpdate{
+ Name: input.Name, ReportRetentionDays: input.ReportRetentionDays,
+ })
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) listMembers(c *gin.Context) {
+ id, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.identity.Members(c, a, id, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) removeMember(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ userID, ok := parameterID(c, "userID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.identity.RemoveMember(c, a, workspaceID, userID); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+func (s *Server) listInvitations(c *gin.Context) {
+ id, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.identity.Invitations(c, a, id, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) createInvitation(c *gin.Context) {
+ id, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Email string `json:"email"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.identity.Invite(c, a, id, input.Email)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusCreated, item)
+}
+func (s *Server) revokeInvitation(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ invitationID, ok := parameterID(c, "invitationID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.identity.RevokeInvitation(c, a, workspaceID, invitationID); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+var _ = uuid.Nil
diff --git a/internal/platform/httpserver/identity_rbac_test.go b/internal/platform/httpserver/identity_rbac_test.go
new file mode 100644
index 0000000..1eae16c
--- /dev/null
+++ b/internal/platform/httpserver/identity_rbac_test.go
@@ -0,0 +1,326 @@
+package httpserver
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/google/uuid"
+)
+
+type identityStore struct {
+ user identity.User
+ workspace identity.Workspace
+ role string
+ invitationMade bool
+ invitationAccepted bool
+ memberRemoved bool
+ workspaceCreated bool
+ removeResult bool
+ workspaceUpdate identity.WorkspaceUpdate
+}
+
+func (s *identityStore) ResolveIdentity(context.Context, string, auth.Identity) (identity.User, error) {
+ return s.user, nil
+}
+func (s *identityStore) ActivateUser(context.Context, uuid.UUID) (identity.User, error) {
+ return s.user, nil
+}
+func (s *identityStore) GetUser(context.Context, uuid.UUID) (identity.User, error) {
+ return s.user, nil
+}
+func (s *identityStore) ListWorkspaces(context.Context, uuid.UUID) ([]identity.Workspace, error) {
+ return []identity.Workspace{s.workspace}, nil
+}
+func (s *identityStore) CreateWorkspace(context.Context, uuid.UUID, string, string) (identity.Workspace, error) {
+ s.workspaceCreated = true
+ return s.workspace, nil
+}
+func (s *identityStore) GetMembership(context.Context, uuid.UUID, uuid.UUID) (identity.Membership, error) {
+ return identity.Membership{Workspace: s.workspace, Role: s.role}, nil
+}
+func (s *identityStore) UpdateWorkspace(_ context.Context, _ uuid.UUID, update identity.WorkspaceUpdate) (identity.Workspace, error) {
+ s.workspaceUpdate = update
+ return s.workspace, nil
+}
+func (s *identityStore) ListMembers(context.Context, uuid.UUID, pagination.Request) (pagination.Page[identity.Member], error) {
+ return pagination.Page[identity.Member]{Items: []identity.Member{{UserID: s.user.ID, Role: s.role}}}, nil
+}
+func (s *identityStore) RemoveMember(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
+ s.memberRemoved = true
+
+ return s.removeResult, nil
+}
+func (s *identityStore) CreateInvitation(_ context.Context, workspaceID, _ uuid.UUID, email string, _ []byte, expiresAt time.Time) (identity.Invitation, error) {
+ s.invitationMade = true
+ return identity.Invitation{ID: uuid.New(), WorkspaceID: workspaceID, Email: email, ExpiresAt: expiresAt}, nil
+}
+func (s *identityStore) ListInvitations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[identity.Invitation], error) {
+ return pagination.Page[identity.Invitation]{}, nil
+}
+func (s *identityStore) RevokeInvitation(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
+ return true, nil
+}
+func (s *identityStore) AcceptInvitation(context.Context, uuid.UUID, []byte, string) (identity.Workspace, error) {
+ s.invitationAccepted = true
+ return s.workspace, nil
+}
+
+func TestPendingAccountCanOnboardButCannotUseSaaSResources(t *testing.T) {
+ store := &identityStore{user: identity.User{ID: uuid.New(), Status: "pending"}}
+ config := testHTTPConfig()
+ config.RegistrationMode = "public"
+ server, err := New(config, identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ sessionRequest := httptest.NewRequest(http.MethodGet, "/api/v1/session", nil)
+ sessionRequest.Header.Set("Remote-Sub", "github-user")
+ sessionResponse := httptest.NewRecorder()
+ server.Handler().ServeHTTP(sessionResponse, sessionRequest)
+ if sessionResponse.Code != http.StatusOK {
+ t.Fatalf("pending session = %d %s", sessionResponse.Code, sessionResponse.Body.String())
+ }
+
+ token := issueToken(t, server.Handler())
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces", bytes.NewBufferString(`{"name":"Bypass"}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Remote-Sub", "github-user")
+ request.Header.Set("X-CSRF-Token", token)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), `"code":"account.onboarding_required"`) {
+ t.Fatalf("pending resource access = %d %s", response.Code, response.Body.String())
+ }
+ if store.workspaceCreated {
+ t.Fatal("pending account created a workspace")
+ }
+}
+
+func TestDisabledRegistrationRejectsInvitationAcceptance(t *testing.T) {
+ store := &identityStore{user: identity.User{ID: uuid.New(), Status: "pending"}}
+ config := testHTTPConfig()
+ config.RegistrationMode = "disabled"
+ server, err := New(config, identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := issueToken(t, server.Handler())
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/invitations/accept", bytes.NewBufferString(`{"token":"invitation","acceptTerms":true,"acceptPrivacy":true}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Remote-Sub", "github-user")
+ request.Header.Set("X-CSRF-Token", token)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), `"code":"account.registration_closed"`) {
+ t.Fatalf("disabled invitation acceptance = %d %s", response.Code, response.Body.String())
+ }
+ if store.invitationAccepted {
+ t.Fatal("disabled registration accepted an invitation")
+ }
+}
+
+func TestWorkspaceInvitationRequiresAdminRole(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ role string
+ wantStatus int
+ wantCode string
+ }{
+ {name: "member forbidden", role: "member", wantStatus: http.StatusForbidden, wantCode: "workspace.forbidden"},
+ {name: "admin allowed", role: "admin", wantStatus: http.StatusCreated},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ workspaceID := uuid.New()
+ store := &identityStore{
+ user: identity.User{ID: uuid.New(), Username: "octocat", Email: "octocat@example.com", Status: "active"},
+ workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode"},
+ role: test.role,
+ }
+ identityService := identity.NewService(store, "tinyauth")
+ csrf := auth.NewCSRF([]byte("01234567890123456789012345678901"))
+ server, err := New(testHTTPConfig(), identityService, nil, nil, nil, nil, nil, csrf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := issueToken(t, server.Handler())
+
+ request := httptest.NewRequest(http.MethodPost, "/api/v1/workspaces/"+workspaceID.String()+"/invitations", bytes.NewBufferString(`{"email":"new@example.com"}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Remote-Sub", "github-user")
+ request.Header.Set("X-CSRF-Token", token)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != test.wantStatus {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if test.wantCode != "" && !strings.Contains(response.Body.String(), `"code":"`+test.wantCode+`"`) {
+ t.Fatalf("body %s does not contain error code %q", response.Body.String(), test.wantCode)
+ }
+ if store.invitationMade != (test.wantStatus == http.StatusCreated) {
+ t.Fatalf("invitationMade = %v", store.invitationMade)
+ }
+ })
+ }
+}
+
+func TestWorkspaceMembersAllowMemberRead(t *testing.T) {
+ workspaceID := uuid.New()
+ store := &identityStore{
+ user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"},
+ workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode"},
+ role: "member",
+ }
+ server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/"+workspaceID.String()+"/members", nil)
+ request.Header.Set("Remote-Sub", "github-user")
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), store.user.ID.String()) {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+}
+
+func TestWorkspaceSettingsUpdateRetentionPolicy(t *testing.T) {
+ workspaceID := uuid.New()
+ store := &identityStore{
+ user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"},
+ workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode", ReportRetentionDays: 90},
+ role: "admin",
+ }
+ server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := issueToken(t, server.Handler())
+ request := httptest.NewRequest(http.MethodPatch, "/api/v1/workspaces/"+workspaceID.String(), bytes.NewBufferString(`{"reportRetentionDays":180}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Remote-Sub", "github-user")
+ request.Header.Set("X-CSRF-Token", token)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if store.workspaceUpdate.ReportRetentionDays == nil || *store.workspaceUpdate.ReportRetentionDays != 180 || store.workspaceUpdate.Name != nil {
+ t.Fatalf("workspace update = %+v", store.workspaceUpdate)
+ }
+}
+
+func TestWorkspaceSettingsRejectInvalidRetentionPolicy(t *testing.T) {
+ workspaceID := uuid.New()
+ store := &identityStore{
+ user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"},
+ workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode", ReportRetentionDays: 90}, role: "admin",
+ }
+ server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := issueToken(t, server.Handler())
+ request := httptest.NewRequest(http.MethodPatch, "/api/v1/workspaces/"+workspaceID.String(), bytes.NewBufferString(`{"reportRetentionDays":0}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Remote-Sub", "github-user")
+ request.Header.Set("X-CSRF-Token", token)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `"code":"workspace.report_retention_invalid"`) {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+}
+
+func TestIdentityHeadersFromUntrustedPeerAreRejected(t *testing.T) {
+ store := &identityStore{user: identity.User{ID: uuid.New(), Status: "active"}}
+ config := Config{TrustedProxyCIDRs: []string{"127.0.0.1/32"}}
+ server, err := New(config, identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/session", nil)
+ request.Header.Set("Remote-Sub", "forged-user")
+ request.RemoteAddr = "172.30.0.3:54321"
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusUnauthorized || !strings.Contains(response.Body.String(), `"code":"identity.untrusted_gateway"`) {
+ t.Fatalf("untrusted gateway response = %d %s", response.Code, response.Body.String())
+ }
+}
+
+func TestWorkspaceMemberRemovalRequiresAdminAndProtectsOwner(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ role string
+ removeResult bool
+ wantStatus int
+ wantRemoved bool
+ }{
+ {name: "member forbidden", role: "member", removeResult: true, wantStatus: http.StatusForbidden},
+ {name: "admin removes member", role: "admin", removeResult: true, wantStatus: http.StatusNoContent, wantRemoved: true},
+ {name: "owner is protected", role: "admin", removeResult: false, wantStatus: http.StatusConflict, wantRemoved: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ workspaceID := uuid.New()
+ store := &identityStore{
+ user: identity.User{ID: uuid.New(), Username: "octocat", Status: "active"},
+ workspace: identity.Workspace{ID: workspaceID, Name: "MoonCode"},
+ role: test.role, removeResult: test.removeResult,
+ }
+ server, err := New(testHTTPConfig(), identity.NewService(store, "tinyauth"), nil, nil, nil, nil, nil, auth.NewCSRF([]byte("01234567890123456789012345678901")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ token := issueToken(t, server.Handler())
+ request := httptest.NewRequest(http.MethodDelete, "/api/v1/workspaces/"+workspaceID.String()+"/members/"+uuid.NewString(), nil)
+ request.Header.Set("Remote-Sub", "github-user")
+ request.Header.Set("X-CSRF-Token", token)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != test.wantStatus {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ if store.memberRemoved != test.wantRemoved {
+ t.Fatalf("memberRemoved = %v, want %v", store.memberRemoved, test.wantRemoved)
+ }
+ if !test.removeResult && test.wantRemoved && !strings.Contains(response.Body.String(), "Member cannot be removed") {
+ t.Fatalf("owner protection response is not explicit: %s", response.Body.String())
+ }
+ })
+ }
+}
+
+func issueToken(t *testing.T, handler http.Handler) string {
+ t.Helper()
+ request := httptest.NewRequest(http.MethodGet, "/api/v1/csrf", nil)
+ request.Header.Set("Remote-Sub", "github-user")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("issue CSRF: status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var body struct {
+ Token string `json:"token"`
+ }
+ if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
+ t.Fatal(err)
+ }
+ return body.Token
+}
diff --git a/internal/platform/httpserver/metrics.go b/internal/platform/httpserver/metrics.go
new file mode 100644
index 0000000..ed4028b
--- /dev/null
+++ b/internal/platform/httpserver/metrics.go
@@ -0,0 +1,114 @@
+package httpserver
+
+import (
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+var durationBuckets = [...]float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
+
+type RequestMetrics interface {
+ Observe(method, route string, status int, duration time.Duration)
+ Handler() http.Handler
+}
+
+type requestMetric struct {
+ count uint64
+ sum float64
+ buckets [len(durationBuckets)]uint64
+}
+
+type Metrics struct {
+ mu sync.Mutex
+ requests map[string]*requestMetric
+ tasks map[string]*requestMetric
+ retention retentionMetrics
+}
+
+func NewMetrics() *Metrics {
+ return &Metrics{requests: make(map[string]*requestMetric), tasks: make(map[string]*requestMetric)}
+}
+
+func (m *Metrics) Observe(method, route string, status int, duration time.Duration) {
+ if route == "" {
+ route = "unmatched"
+ }
+ key := method + "\x00" + route + "\x00" + strconv.Itoa(status)
+ seconds := duration.Seconds()
+
+ m.mu.Lock()
+ metric := m.requests[key]
+ if metric == nil {
+ metric = &requestMetric{}
+ m.requests[key] = metric
+ }
+ metric.count++
+ metric.sum += seconds
+ for index, upperBound := range durationBuckets {
+ if seconds <= upperBound {
+ metric.buckets[index]++
+ }
+ }
+ m.mu.Unlock()
+}
+
+func (m *Metrics) Handler() http.Handler {
+ return http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
+ writer.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
+ writer.Header().Set("Cache-Control", "no-store")
+
+ m.mu.Lock()
+ keys := make([]string, 0, len(m.requests))
+ snapshot := make(map[string]requestMetric, len(m.requests))
+ for key, metric := range m.requests {
+ keys = append(keys, key)
+ snapshot[key] = *metric
+ }
+ taskKeys := make([]string, 0, len(m.tasks))
+ taskSnapshot := make(map[string]requestMetric, len(m.tasks))
+ for key, metric := range m.tasks {
+ taskKeys = append(taskKeys, key)
+ taskSnapshot[key] = *metric
+ }
+ retention := m.retention
+ m.mu.Unlock()
+ sort.Strings(keys)
+ sort.Strings(taskKeys)
+
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_http_requests_total Total HTTP requests handled by MoonCode.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_http_requests_total counter")
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_http_request_duration_seconds HTTP request duration in seconds.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_http_request_duration_seconds histogram")
+ for _, key := range keys {
+ parts := strings.Split(key, "\x00")
+ labels := fmt.Sprintf(`method=%q,route=%q,status=%q`, parts[0], parts[1], parts[2])
+ metric := snapshot[key]
+ _, _ = fmt.Fprintf(writer, "mooncode_http_requests_total{%s} %d\n", labels, metric.count)
+ for index, upperBound := range durationBuckets {
+ _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_bucket{%s,le=%q} %d\n", labels, strconv.FormatFloat(upperBound, 'f', -1, 64), metric.buckets[index])
+ }
+ _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, metric.count)
+ _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_sum{%s} %g\n", labels, metric.sum)
+ _, _ = fmt.Fprintf(writer, "mooncode_http_request_duration_seconds_count{%s} %d\n", labels, metric.count)
+ }
+
+ writeTaskMetrics(writer, taskKeys, taskSnapshot)
+ writeRetentionMetrics(writer, retention)
+ })
+}
+
+func observeRequests(metrics RequestMetrics) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ started := time.Now()
+ c.Next()
+
+ metrics.Observe(c.Request.Method, c.FullPath(), c.Writer.Status(), time.Since(started))
+ }
+}
diff --git a/internal/platform/httpserver/middleware.go b/internal/platform/httpserver/middleware.go
new file mode 100644
index 0000000..39b8223
--- /dev/null
+++ b/internal/platform/httpserver/middleware.go
@@ -0,0 +1,60 @@
+package httpserver
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+)
+
+func requireActiveAccount() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ actor, ok := auth.ActorFrom(c.Request.Context())
+ if !ok || actor.Status != "active" {
+ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
+ "error": gin.H{
+ "code": "account.onboarding_required",
+ "message": "Complete account registration first",
+ "requestId": auth.RequestID(c),
+ },
+ })
+
+ return
+ }
+
+ c.Next()
+ }
+}
+
+func securityHeaders() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Header("X-Frame-Options", "DENY")
+ c.Header("Referrer-Policy", "no-referrer")
+ c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
+ c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
+ c.Next()
+ }
+}
+
+func limitBody(maxBytes int64) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if c.Request.Body != nil {
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
+ }
+ c.Next()
+ }
+}
+
+func requestTimeout(timeout time.Duration) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
+ defer cancel()
+
+ c.Request = c.Request.WithContext(ctx)
+ c.Next()
+ }
+}
diff --git a/internal/platform/httpserver/observability_test.go b/internal/platform/httpserver/observability_test.go
new file mode 100644
index 0000000..9547c34
--- /dev/null
+++ b/internal/platform/httpserver/observability_test.go
@@ -0,0 +1,79 @@
+package httpserver
+
+import (
+ "bytes"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/gin-gonic/gin"
+)
+
+func TestMetricsUsesRouteTemplatesAndBoundedLabels(t *testing.T) {
+ metrics := NewMetrics()
+ metrics.Observe(http.MethodGet, "/api/v1/workspaces/:workspaceID/repositories", http.StatusOK, 25*time.Millisecond)
+ metrics.ObserveTask("retention-cleanup", "succeeded", 50*time.Millisecond)
+ metrics.AddRetentionProgress(3, 2, 1)
+
+ request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ response := httptest.NewRecorder()
+ metrics.Handler().ServeHTTP(response, request)
+ body := response.Body.String()
+ if response.Code != http.StatusOK || !strings.Contains(body, `mooncode_http_requests_total{method="GET",route="/api/v1/workspaces/:workspaceID/repositories",status="200"} 1`) {
+ t.Fatalf("unexpected metrics response: %d %s", response.Code, body)
+ }
+ if !strings.Contains(body, `mooncode_http_request_duration_seconds_count{method="GET",route="/api/v1/workspaces/:workspaceID/repositories",status="200"} 1`) {
+ t.Fatalf("duration histogram is missing: %s", body)
+ }
+ if !strings.Contains(body, `mooncode_hatchet_task_runs_total{task="retention-cleanup",status="succeeded"} 1`) {
+ t.Fatalf("Hatchet task metrics are missing: %s", body)
+ }
+ for _, metric := range []string{
+ "mooncode_retention_deleted_runs_total 3",
+ "mooncode_retention_purged_snapshots_total 2",
+ "mooncode_retention_requeued_repositories_total 1",
+ } {
+ if !strings.Contains(body, metric) {
+ t.Fatalf("retention metric %q is missing: %s", metric, body)
+ }
+ }
+}
+
+func TestTaskMetricsBoundsCallerProvidedLabels(t *testing.T) {
+ metrics := NewMetrics()
+ metrics.ObserveTask("workspace-"+strings.Repeat("x", 100), "custom-status", time.Millisecond)
+
+ request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ response := httptest.NewRecorder()
+ metrics.Handler().ServeHTTP(response, request)
+ body := response.Body.String()
+ if !strings.Contains(body, `task="unknown",status="unknown"`) || strings.Contains(body, strings.Repeat("x", 100)) {
+ t.Fatalf("task metrics accepted unbounded labels: %s", body)
+ }
+}
+
+func TestAccessLogDoesNotRecordQueryValues(t *testing.T) {
+ var output bytes.Buffer
+ logger := slog.New(slog.NewJSONHandler(&output, nil))
+ router := gin.New()
+ if err := router.SetTrustedProxies(nil); err != nil {
+ t.Fatal(err)
+ }
+ router.Use(auth.RequestIDMiddleware(), accessLog(logger))
+ router.GET("/resource/:id", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+
+ request := httptest.NewRequest(http.MethodGet, "/resource/123?token=sensitive-value", nil)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+ logged := output.String()
+ if !strings.Contains(logged, `"route":"/resource/:id"`) || !strings.Contains(logged, `"status":204`) {
+ t.Fatalf("unexpected access log: %s", logged)
+ }
+ if strings.Contains(logged, "sensitive-value") || strings.Contains(logged, `"token"`) {
+ t.Fatalf("access log leaked query values: %s", logged)
+ }
+}
diff --git a/internal/platform/httpserver/openapi_routes_test.go b/internal/platform/httpserver/openapi_routes_test.go
new file mode 100644
index 0000000..29a9992
--- /dev/null
+++ b/internal/platform/httpserver/openapi_routes_test.go
@@ -0,0 +1,84 @@
+package httpserver
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "testing"
+
+ openapidoc "github.com/fuchencong/mooncode/api/openapi"
+ channelcommand "github.com/fuchencong/mooncode/internal/channel/command"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/getkin/kin-openapi/openapi3"
+ "github.com/gin-gonic/gin"
+)
+
+var ginParameterPattern = regexp.MustCompile(`:([A-Za-z0-9_]+)`)
+
+func TestRoutesMatchOpenAPI(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ server, err := New(
+ Config{TrustedProxyCIDRs: []string{"127.0.0.1/32"}},
+ nil,
+ nil,
+ nil,
+ nil,
+ nil,
+ &channelcommand.Service{},
+ auth.NewCSRF([]byte("openapi-route-test-key")),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ document, err := openapidoc.Document()
+ if err != nil {
+ t.Fatal(err)
+ }
+ specification, err := openapi3.NewLoader().LoadFromData(document)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ implemented := make(map[string]struct{})
+ for _, route := range server.router.Routes() {
+ path := ginParameterPattern.ReplaceAllString(route.Path, `{$1}`)
+ implemented[route.Method+" "+path] = struct{}{}
+ }
+
+ documented := make(map[string]struct{})
+ for path, item := range specification.Paths.Map() {
+ for method := range item.Operations() {
+ documented[method+" "+path] = struct{}{}
+ }
+ }
+
+ if missing := routeDifference(implemented, documented); len(missing) > 0 {
+ t.Errorf("implemented routes missing from OpenAPI:\n%s", formatRoutes(missing))
+ }
+ if missing := routeDifference(documented, implemented); len(missing) > 0 {
+ t.Errorf("OpenAPI routes missing from implementation:\n%s", formatRoutes(missing))
+ }
+}
+
+func routeDifference(left, right map[string]struct{}) []string {
+ var difference []string
+ for route := range left {
+ if _, ok := right[route]; !ok {
+ difference = append(difference, route)
+ }
+ }
+ sort.Strings(difference)
+
+ return difference
+}
+
+func formatRoutes(routes []string) string {
+ formatted := ""
+ for _, route := range routes {
+ formatted += fmt.Sprintf("- %s\n", route)
+ }
+
+ return formatted
+}
diff --git a/internal/platform/httpserver/options.go b/internal/platform/httpserver/options.go
new file mode 100644
index 0000000..845d9f7
--- /dev/null
+++ b/internal/platform/httpserver/options.go
@@ -0,0 +1,34 @@
+package httpserver
+
+import (
+ "context"
+ "log/slog"
+)
+
+type Readiness interface {
+ Check(context.Context) error
+}
+
+type Option func(*Server)
+
+func WithReadiness(readiness Readiness) Option {
+ return func(server *Server) {
+ server.readiness = readiness
+ }
+}
+
+func WithLogger(logger *slog.Logger) Option {
+ return func(server *Server) {
+ if logger != nil {
+ server.logger = logger
+ }
+ }
+}
+
+func WithMetrics(metrics RequestMetrics) Option {
+ return func(server *Server) {
+ if metrics != nil {
+ server.metrics = metrics
+ }
+ }
+}
diff --git a/internal/platform/httpserver/providers.go b/internal/platform/httpserver/providers.go
new file mode 100644
index 0000000..c268b4b
--- /dev/null
+++ b/internal/platform/httpserver/providers.go
@@ -0,0 +1,104 @@
+package httpserver
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+func (s *Server) registerProviders(api *gin.RouterGroup) {
+ group := api.Group("/me/provider-connections")
+ group.GET("", s.listProviderConnections)
+ group.POST("", s.createProviderConnection)
+ group.PATCH("/:connectionID", s.replaceProviderToken)
+ group.POST("/:connectionID/test", s.testProviderConnection)
+ group.POST("/:connectionID/default", s.defaultProviderConnection)
+ group.DELETE("/:connectionID", s.deleteProviderConnection)
+}
+func (s *Server) listProviderConnections(c *gin.Context) {
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.providers.List(c, a.UserID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) createProviderConnection(c *gin.Context) {
+ var input struct {
+ ProviderType string `json:"providerType"`
+ BaseURL string `json:"baseUrl"`
+ Token string `json:"token"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.providers.Create(c, a.UserID, input.ProviderType, input.BaseURL, input.Token)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusCreated, item)
+}
+func (s *Server) replaceProviderToken(c *gin.Context) {
+ id, ok := parameterID(c, "connectionID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Token string `json:"token"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.providers.ReplaceToken(c, a.UserID, id, input.Token)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) testProviderConnection(c *gin.Context) {
+ id, ok := parameterID(c, "connectionID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.providers.Test(c, a.UserID, id)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) defaultProviderConnection(c *gin.Context) {
+ id, ok := parameterID(c, "connectionID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.providers.SetDefault(c, a.UserID, id)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) deleteProviderConnection(c *gin.Context) {
+ id, ok := parameterID(c, "connectionID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.providers.Delete(c, a.UserID, id); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
diff --git a/internal/platform/httpserver/repositories.go b/internal/platform/httpserver/repositories.go
new file mode 100644
index 0000000..d399718
--- /dev/null
+++ b/internal/platform/httpserver/repositories.go
@@ -0,0 +1,234 @@
+package httpserver
+
+import (
+ "net/http"
+
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func (s *Server) registerRepositories(api *gin.RouterGroup) {
+ group := api.Group("/workspaces/:workspaceID/repositories")
+ group.GET("", s.listRepositories)
+ group.POST("", s.createRepository)
+ group.GET("/:repositoryID", s.getRepository)
+ group.PATCH("/:repositoryID", s.updateRepository)
+ group.DELETE("/:repositoryID", s.deleteRepository)
+ group.POST("/:repositoryID/refresh", s.refreshRepository)
+ group.POST("/:repositoryID/archive", s.archiveRepository)
+ group.POST("/:repositoryID/restore", s.restoreRepository)
+ group.GET("/:repositoryID/operations", s.listRepositoryOperations)
+ group.GET("/:repositoryID/snapshots", s.listRepositorySnapshots)
+ group.POST("/:repositoryID/operations/:operationID/cancel", s.cancelRepositoryOperation)
+}
+func (s *Server) getRepository(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.repositories.Get(c, a, workspaceID, repositoryID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+
+func (s *Server) listRepositorySnapshots(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.repositories.Snapshots(c, a, workspaceID, repositoryID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) listRepositories(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.repositories.List(c, a, workspaceID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) createRepository(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Name string `json:"name"`
+ RemoteURL string `json:"remoteUrl"`
+ Ref string `json:"ref"`
+ ProviderConnectionID *uuid.UUID `json:"providerConnectionId"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, operation, err := s.repositories.Create(c, a, workspaceID, repository.CreateInput{Name: input.Name, RemoteURL: input.RemoteURL, Ref: input.Ref, ProviderConnectionID: input.ProviderConnectionID})
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusAccepted, gin.H{"repository": item, "operation": operation})
+}
+func (s *Server) updateRepository(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ var input struct {
+ Name string `json:"name"`
+ RemoteURL string `json:"remoteUrl"`
+ Ref string `json:"ref"`
+ ProviderConnectionID *uuid.UUID `json:"providerConnectionId"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ item, operation, err := s.repositories.Update(c, a, workspaceID, repositoryID, repository.UpdateInput{
+ Name: input.Name, RemoteURL: input.RemoteURL, Ref: input.Ref, ProviderConnectionID: input.ProviderConnectionID,
+ })
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ status := http.StatusOK
+ if operation != nil {
+ status = http.StatusAccepted
+ }
+ c.JSON(status, gin.H{"repository": item, "operation": operation})
+}
+func (s *Server) refreshRepository(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ var input struct {
+ ProviderConnectionID *uuid.UUID `json:"providerConnectionId"`
+ }
+ if !bind(c, &input) {
+ return
+ }
+ a, _ := actor(c)
+ operation, err := s.repositories.Refresh(c, a, workspaceID, repositoryID, input.ProviderConnectionID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusAccepted, operation)
+}
+func (s *Server) deleteRepository(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ operation, err := s.repositories.Delete(c, a, workspaceID, repositoryID)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusAccepted, operation)
+}
+func (s *Server) archiveRepository(c *gin.Context) { s.setRepositoryArchive(c, true) }
+func (s *Server) restoreRepository(c *gin.Context) { s.setRepositoryArchive(c, false) }
+func (s *Server) setRepositoryArchive(c *gin.Context, archived bool) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ item, err := s.repositories.Archive(c, a, workspaceID, repositoryID, archived)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, item)
+}
+func (s *Server) listRepositoryOperations(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ page, ok := pageRequest(c)
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ items, err := s.repositories.Operations(c, a, workspaceID, repositoryID, page)
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.JSON(http.StatusOK, items)
+}
+func (s *Server) cancelRepositoryOperation(c *gin.Context) {
+ workspaceID, ok := parameterID(c, "workspaceID")
+ if !ok {
+ return
+ }
+ repositoryID, ok := parameterID(c, "repositoryID")
+ if !ok {
+ return
+ }
+ operationID, ok := parameterID(c, "operationID")
+ if !ok {
+ return
+ }
+ a, _ := actor(c)
+ if err := s.repositories.Cancel(c, a, workspaceID, repositoryID, operationID); err != nil {
+ failure(c, err)
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
diff --git a/internal/platform/httpserver/retention_metrics.go b/internal/platform/httpserver/retention_metrics.go
new file mode 100644
index 0000000..a140c31
--- /dev/null
+++ b/internal/platform/httpserver/retention_metrics.go
@@ -0,0 +1,38 @@
+package httpserver
+
+import (
+ "fmt"
+ "io"
+)
+
+type retentionMetrics struct {
+ deletedRuns uint64
+ purgedSnapshots uint64
+ requeuedRepositories uint64
+}
+
+func (m *Metrics) AddRetentionProgress(deletedRuns, purgedSnapshots, requeuedRepositories int) {
+ m.mu.Lock()
+ if deletedRuns > 0 {
+ m.retention.deletedRuns += uint64(deletedRuns)
+ }
+ if purgedSnapshots > 0 {
+ m.retention.purgedSnapshots += uint64(purgedSnapshots)
+ }
+ if requeuedRepositories > 0 {
+ m.retention.requeuedRepositories += uint64(requeuedRepositories)
+ }
+ m.mu.Unlock()
+}
+
+func writeRetentionMetrics(writer io.Writer, metrics retentionMetrics) {
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_retention_deleted_runs_total Expired terminal analysis runs deleted by retention cleanup.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_retention_deleted_runs_total counter")
+ _, _ = fmt.Fprintf(writer, "mooncode_retention_deleted_runs_total %d\n", metrics.deletedRuns)
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_retention_purged_snapshots_total Unreferenced Git snapshot pins released by retention cleanup.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_retention_purged_snapshots_total counter")
+ _, _ = fmt.Fprintf(writer, "mooncode_retention_purged_snapshots_total %d\n", metrics.purgedSnapshots)
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_retention_requeued_repositories_total Logically deleted repositories requeued for physical purge.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_retention_requeued_repositories_total counter")
+ _, _ = fmt.Fprintf(writer, "mooncode_retention_requeued_repositories_total %d\n", metrics.requeuedRepositories)
+}
diff --git a/internal/platform/httpserver/server.go b/internal/platform/httpserver/server.go
new file mode 100644
index 0000000..fd1812d
--- /dev/null
+++ b/internal/platform/httpserver/server.go
@@ -0,0 +1,98 @@
+package httpserver
+
+import (
+ "log/slog"
+ "net/http"
+ "time"
+
+ openapidoc "github.com/fuchencong/mooncode/api/openapi"
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ channel "github.com/fuchencong/mooncode/internal/channel/biz"
+ channelcommand "github.com/fuchencong/mooncode/internal/channel/command"
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ "github.com/gin-gonic/gin"
+)
+
+type Config struct {
+ LogoutURL string
+ RegistrationMode string
+ TermsVersion string
+ PrivacyVersion string
+ TrustedProxyCIDRs []string
+ MaxBodyBytes int64
+ RequestTimeout time.Duration
+}
+
+type Server struct {
+ config Config
+ identity *identity.Service
+ providers *identity.ProviderService
+ repositories *repository.Service
+ analyses *analysis.Service
+ channels *channel.Service
+ commands *channelcommand.Service
+ readiness Readiness
+ logger *slog.Logger
+ metrics RequestMetrics
+ csrf *auth.CSRF
+ router *gin.Engine
+}
+
+func New(config Config, identityService *identity.Service, providers *identity.ProviderService, repositories *repository.Service, analyses *analysis.Service, channels *channel.Service, commands *channelcommand.Service, csrf *auth.CSRF, options ...Option) (*Server, error) {
+ if config.MaxBodyBytes <= 0 {
+ config.MaxBodyBytes = 1 << 20
+ }
+ if config.RequestTimeout <= 0 {
+ config.RequestTimeout = 30 * time.Second
+ }
+ trusted, err := auth.NewTrustedProxies(config.TrustedProxyCIDRs)
+ if err != nil {
+ return nil, err
+ }
+ server := &Server{config: config, identity: identityService, providers: providers, repositories: repositories, analyses: analyses, channels: channels, commands: commands, csrf: csrf, logger: slog.Default(), metrics: NewMetrics()}
+ for _, option := range options {
+ option(server)
+ }
+ router := gin.New()
+ if err := router.SetTrustedProxies(config.TrustedProxyCIDRs); err != nil {
+ return nil, err
+ }
+ router.Use(auth.RequestIDMiddleware(), accessLog(server.logger), observeRequests(server.metrics), gin.Recovery(), securityHeaders(), limitBody(config.MaxBodyBytes), requestTimeout(config.RequestTimeout))
+ router.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) })
+ router.GET("/readyz", server.ready)
+ router.GET("/metrics", gin.WrapH(server.metrics.Handler()))
+ router.GET("/api/openapi.yaml", server.openAPI)
+ api := router.Group("/api/v1")
+ api.Use(auth.Require(identityService, trusted), csrf.Middleware())
+ active := api.Group("")
+ active.Use(requireActiveAccount())
+ server.registerIdentity(api, active)
+ server.registerProviders(active)
+ server.registerRepositories(active)
+ server.registerAnalysis(active)
+ server.registerChannels(active)
+ server.router = router
+ return server, nil
+}
+
+func (s *Server) openAPI(c *gin.Context) {
+ document, err := openapidoc.Document()
+ if err != nil {
+ failure(c, err)
+ return
+ }
+ c.Data(http.StatusOK, "application/yaml; charset=utf-8", document)
+}
+
+func (s *Server) Handler() http.Handler { return s.router }
+
+func (s *Server) ready(c *gin.Context) {
+ if s.readiness == nil || s.readiness.Check(c.Request.Context()) != nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"status": "ready"})
+}
diff --git a/internal/platform/httpserver/server_test.go b/internal/platform/httpserver/server_test.go
new file mode 100644
index 0000000..c521dc1
--- /dev/null
+++ b/internal/platform/httpserver/server_test.go
@@ -0,0 +1,114 @@
+package httpserver
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/gin-gonic/gin"
+)
+
+type readinessFunc func(context.Context) error
+
+func (check readinessFunc) Check(ctx context.Context) error {
+ return check(ctx)
+}
+
+func testHTTPConfig() Config {
+ return Config{TrustedProxyCIDRs: []string{"192.0.2.1/32"}}
+}
+
+func TestHealthAndReadinessAreSeparateAndHardened(t *testing.T) {
+ server, err := New(
+ testHTTPConfig(), nil, nil, nil, nil, nil, nil,
+ auth.NewCSRF([]byte("01234567890123456789012345678901")),
+ WithReadiness(readinessFunc(func(context.Context) error { return nil })),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ for _, endpoint := range []string{"/healthz", "/readyz"} {
+ request := httptest.NewRequest(http.MethodGet, endpoint, nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+ if response.Code != http.StatusOK {
+ t.Fatalf("%s status = %d, body = %s", endpoint, response.Code, response.Body.String())
+ }
+ if response.Header().Get("X-Content-Type-Options") != "nosniff" || response.Header().Get("X-Frame-Options") != "DENY" {
+ t.Fatalf("%s is missing security headers: %v", endpoint, response.Header())
+ }
+ }
+}
+
+func TestReadinessFailureDoesNotExposeInternalError(t *testing.T) {
+ server, err := New(
+ testHTTPConfig(), nil, nil, nil, nil, nil, nil,
+ auth.NewCSRF([]byte("01234567890123456789012345678901")),
+ WithReadiness(readinessFunc(func(context.Context) error { return errors.New("database password leaked") })),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ request := httptest.NewRequest(http.MethodGet, "/readyz", nil)
+ response := httptest.NewRecorder()
+ server.Handler().ServeHTTP(response, request)
+
+ if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), "password") {
+ t.Fatalf("readiness response = %d %s", response.Code, response.Body.String())
+ }
+}
+
+func TestBodyLimitUsesStableErrorContract(t *testing.T) {
+ router := gin.New()
+ router.Use(auth.RequestIDMiddleware(), limitBody(8))
+ router.POST("/", func(c *gin.Context) {
+ var input map[string]any
+ if !bind(c, &input) {
+ return
+ }
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"value":"too large"}`))
+ request.Header.Set("Content-Type", "application/json")
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+
+ if response.Code != http.StatusRequestEntityTooLarge || !strings.Contains(response.Body.String(), `"code":"request.body_too_large"`) || !strings.Contains(response.Body.String(), `"requestId":`) {
+ t.Fatalf("body limit response = %d %s", response.Code, response.Body.String())
+ }
+}
+
+func TestRequestTimeoutUsesStableErrorContract(t *testing.T) {
+ router := gin.New()
+ router.Use(auth.RequestIDMiddleware(), requestTimeout(time.Millisecond))
+ router.GET("/", func(c *gin.Context) {
+ <-c.Request.Context().Done()
+ failure(c, c.Request.Context().Err())
+ })
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+
+ if response.Code != http.StatusGatewayTimeout || !strings.Contains(response.Body.String(), `"code":"request.timeout"`) {
+ t.Fatalf("timeout response = %d %s", response.Code, response.Body.String())
+ }
+}
+
+func TestUnexpectedErrorIsNotExposed(t *testing.T) {
+ router := gin.New()
+ router.Use(auth.RequestIDMiddleware())
+ router.GET("/", func(c *gin.Context) { failure(c, errors.New("private database detail")) })
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ response := httptest.NewRecorder()
+ router.ServeHTTP(response, request)
+
+ if response.Code != http.StatusInternalServerError || !strings.Contains(response.Body.String(), `"code":"internal.error"`) || strings.Contains(response.Body.String(), "database detail") {
+ t.Fatalf("internal error response = %d %s", response.Code, response.Body.String())
+ }
+}
diff --git a/internal/platform/httpserver/task_metrics.go b/internal/platform/httpserver/task_metrics.go
new file mode 100644
index 0000000..8d2c8ca
--- /dev/null
+++ b/internal/platform/httpserver/task_metrics.go
@@ -0,0 +1,62 @@
+package httpserver
+
+import (
+ "fmt"
+ "io"
+ "strconv"
+ "strings"
+ "time"
+)
+
+var knownTasks = map[string]struct{}{
+ "repository-operation": {},
+ "analysis-run": {},
+ "notification-delivery": {},
+ "retention-scheduler": {},
+ "retention-cleanup": {},
+}
+
+func (m *Metrics) ObserveTask(task, status string, duration time.Duration) {
+ if _, ok := knownTasks[task]; !ok {
+ task = "unknown"
+ }
+ if status != "succeeded" && status != "failed" {
+ status = "unknown"
+ }
+ key := task + "\x00" + status
+ seconds := duration.Seconds()
+
+ m.mu.Lock()
+ metric := m.tasks[key]
+ if metric == nil {
+ metric = &requestMetric{}
+ m.tasks[key] = metric
+ }
+ metric.count++
+ metric.sum += seconds
+ for index, upperBound := range durationBuckets {
+ if seconds <= upperBound {
+ metric.buckets[index]++
+ }
+ }
+ m.mu.Unlock()
+}
+
+func writeTaskMetrics(writer io.Writer, keys []string, metrics map[string]requestMetric) {
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_hatchet_task_runs_total Total MoonCode Hatchet task attempts.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_hatchet_task_runs_total counter")
+ _, _ = fmt.Fprintln(writer, "# HELP mooncode_hatchet_task_duration_seconds MoonCode Hatchet task attempt duration in seconds.")
+ _, _ = fmt.Fprintln(writer, "# TYPE mooncode_hatchet_task_duration_seconds histogram")
+ for _, key := range keys {
+ parts := strings.Split(key, "\x00")
+ labels := fmt.Sprintf(`task=%q,status=%q`, parts[0], parts[1])
+ metric := metrics[key]
+ _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_runs_total{%s} %d\n", labels, metric.count)
+ for index, upperBound := range durationBuckets {
+ _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_bucket{%s,le=%q} %d\n", labels, strconv.FormatFloat(upperBound, 'f', -1, 64), metric.buckets[index])
+ }
+ _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_bucket{%s,le=\"+Inf\"} %d\n", labels, metric.count)
+ _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_sum{%s} %g\n", labels, metric.sum)
+ _, _ = fmt.Fprintf(writer, "mooncode_hatchet_task_duration_seconds_count{%s} %d\n", labels, metric.count)
+ }
+}
diff --git a/internal/platform/pagination/pagination.go b/internal/platform/pagination/pagination.go
new file mode 100644
index 0000000..f018c16
--- /dev/null
+++ b/internal/platform/pagination/pagination.go
@@ -0,0 +1,104 @@
+package pagination
+
+import (
+ "encoding/base64"
+ "encoding/binary"
+ "fmt"
+ "strconv"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+const (
+ DefaultLimit int32 = 50
+ MaxLimit int32 = 100
+ cursorSize = 24
+)
+
+type Cursor struct {
+ Time time.Time
+ ID uuid.UUID
+}
+
+type Request struct {
+ Limit int32
+ After *Cursor
+}
+
+type Page[T any] struct {
+ Items []T `json:"items"`
+ NextCursor string `json:"nextCursor,omitempty"`
+}
+
+func Parse(rawCursor, rawLimit string) (Request, error) {
+ request := Request{Limit: DefaultLimit}
+ if rawLimit != "" {
+ value, err := strconv.ParseInt(rawLimit, 10, 32)
+ if err != nil || value < 1 || value > int64(MaxLimit) {
+ return Request{}, fmt.Errorf("page limit must be between 1 and %d", MaxLimit)
+ }
+ request.Limit = int32(value)
+ }
+ if rawCursor == "" {
+ return request, nil
+ }
+
+ cursor, err := Decode(rawCursor)
+ if err != nil {
+ return Request{}, err
+ }
+ request.After = &cursor
+
+ return request, nil
+}
+
+func Decode(value string) (Cursor, error) {
+ decoded, err := base64.RawURLEncoding.DecodeString(value)
+ if err != nil || len(decoded) != cursorSize {
+ return Cursor{}, fmt.Errorf("page cursor is invalid")
+ }
+
+ var id uuid.UUID
+ copy(id[:], decoded[8:])
+ nanoseconds := int64(binary.BigEndian.Uint64(decoded[:8]))
+ if id == uuid.Nil {
+ return Cursor{}, fmt.Errorf("page cursor is invalid")
+ }
+
+ return Cursor{Time: time.Unix(0, nanoseconds).UTC(), ID: id}, nil
+}
+
+func Encode(at time.Time, id uuid.UUID) string {
+ encoded := make([]byte, cursorSize)
+ binary.BigEndian.PutUint64(encoded[:8], uint64(at.UnixNano()))
+ copy(encoded[8:], id[:])
+
+ return base64.RawURLEncoding.EncodeToString(encoded)
+}
+
+func Build[T any](items []T, limit int32, position func(T) (time.Time, uuid.UUID)) Page[T] {
+ limit = normalizedLimit(limit)
+ page := Page[T]{Items: items}
+ if len(items) <= int(limit) {
+ return page
+ }
+
+ page.Items = items[:limit]
+ at, id := position(page.Items[len(page.Items)-1])
+ page.NextCursor = Encode(at, id)
+
+ return page
+}
+
+func (r Request) QueryLimit() int32 {
+ return normalizedLimit(r.Limit) + 1
+}
+
+func normalizedLimit(limit int32) int32 {
+ if limit < 1 || limit > MaxLimit {
+ return DefaultLimit
+ }
+
+ return limit
+}
diff --git a/internal/platform/pagination/pagination_test.go b/internal/platform/pagination/pagination_test.go
new file mode 100644
index 0000000..8219ff4
--- /dev/null
+++ b/internal/platform/pagination/pagination_test.go
@@ -0,0 +1,51 @@
+package pagination
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+func TestCursorRoundTripAndPageBoundary(t *testing.T) {
+ type item struct {
+ ID uuid.UUID
+ CreatedAt time.Time
+ }
+ items := []item{
+ {ID: uuid.New(), CreatedAt: time.Now().UTC().Truncate(time.Microsecond)},
+ {ID: uuid.New(), CreatedAt: time.Now().UTC().Add(-time.Second).Truncate(time.Microsecond)},
+ {ID: uuid.New(), CreatedAt: time.Now().UTC().Add(-2 * time.Second).Truncate(time.Microsecond)},
+ }
+ page := Build(items, 2, func(value item) (time.Time, uuid.UUID) { return value.CreatedAt, value.ID })
+ if len(page.Items) != 2 || page.NextCursor == "" {
+ t.Fatalf("unexpected page: %+v", page)
+ }
+ cursor, err := Decode(page.NextCursor)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cursor.ID != items[1].ID || !cursor.Time.Equal(items[1].CreatedAt) {
+ t.Fatalf("cursor = %+v, want %+v", cursor, items[1])
+ }
+}
+
+func TestParseRejectsInvalidCursorAndLimit(t *testing.T) {
+ for _, input := range []struct{ cursor, limit string }{
+ {cursor: "not-base64"},
+ {limit: "0"},
+ {limit: "101"},
+ {limit: "not-a-number"},
+ } {
+ if _, err := Parse(input.cursor, input.limit); err == nil {
+ t.Fatalf("Parse(%q, %q) succeeded", input.cursor, input.limit)
+ }
+ }
+}
+
+func TestZeroValueRequestUsesDefaultLimit(t *testing.T) {
+ request := Request{}
+ if request.QueryLimit() != DefaultLimit+1 {
+ t.Fatalf("QueryLimit() = %d, want %d", request.QueryLimit(), DefaultLimit+1)
+ }
+}
diff --git a/internal/platform/secret/cipher.go b/internal/platform/secret/cipher.go
new file mode 100644
index 0000000..e15d308
--- /dev/null
+++ b/internal/platform/secret/cipher.go
@@ -0,0 +1,65 @@
+package secret
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "fmt"
+ "io"
+)
+
+type Cipher interface {
+ Encrypt(plaintext []byte) (ciphertext, nonce []byte, keyVersion int, err error)
+ Decrypt(ciphertext, nonce []byte, keyVersion int) ([]byte, error)
+}
+
+type Option func(*AESGCM)
+
+type AESGCM struct {
+ aead cipher.AEAD
+ keyVersion int
+ random io.Reader
+}
+
+func WithRandom(random io.Reader) Option {
+ return func(c *AESGCM) { c.random = random }
+}
+
+func NewAESGCM(key []byte, keyVersion int, options ...Option) (*AESGCM, error) {
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("create cipher: %w", err)
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("create GCM: %w", err)
+ }
+
+ value := &AESGCM{aead: aead, keyVersion: keyVersion, random: rand.Reader}
+ for _, option := range options {
+ option(value)
+ }
+
+ return value, nil
+}
+
+func (c *AESGCM) Encrypt(plaintext []byte) ([]byte, []byte, int, error) {
+ nonce := make([]byte, c.aead.NonceSize())
+ if _, err := io.ReadFull(c.random, nonce); err != nil {
+ return nil, nil, 0, fmt.Errorf("generate nonce: %w", err)
+ }
+
+ return c.aead.Seal(nil, nonce, plaintext, nil), nonce, c.keyVersion, nil
+}
+
+func (c *AESGCM) Decrypt(ciphertext, nonce []byte, keyVersion int) ([]byte, error) {
+ if keyVersion != c.keyVersion {
+ return nil, fmt.Errorf("unsupported key version %d", keyVersion)
+ }
+ plaintext, err := c.aead.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt secret: %w", err)
+ }
+
+ return plaintext, nil
+}
diff --git a/internal/platform/secret/cipher_test.go b/internal/platform/secret/cipher_test.go
new file mode 100644
index 0000000..ee1b154
--- /dev/null
+++ b/internal/platform/secret/cipher_test.go
@@ -0,0 +1,21 @@
+package secret
+
+import "testing"
+
+func TestAESGCMRoundTrip(t *testing.T) {
+ cipher, err := NewAESGCM(make([]byte, 32), 3)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ciphertext, nonce, version, err := cipher.Encrypt([]byte("github_pat_secret"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ plaintext, err := cipher.Decrypt(ciphertext, nonce, version)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(plaintext) != "github_pat_secret" {
+ t.Fatalf("unexpected plaintext %q", plaintext)
+ }
+}
diff --git a/internal/repository/biz/failure.go b/internal/repository/biz/failure.go
new file mode 100644
index 0000000..e093481
--- /dev/null
+++ b/internal/repository/biz/failure.go
@@ -0,0 +1,6 @@
+package biz
+
+type Failure struct {
+ Code string
+ Message string
+}
diff --git a/internal/repository/biz/model.go b/internal/repository/biz/model.go
new file mode 100644
index 0000000..c2f06cf
--- /dev/null
+++ b/internal/repository/biz/model.go
@@ -0,0 +1,73 @@
+package biz
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Snapshot struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repositoryId"`
+ CommitSHA string `json:"commitSha"`
+ SourceRef string `json:"sourceRef"`
+ AuthorName string `json:"authorName,omitempty"`
+ AuthoredAt *time.Time `json:"authoredAt,omitempty"`
+ Title string `json:"title,omitempty"`
+ SourceState string `json:"sourceState"`
+ CreatedAt time.Time `json:"createdAt"`
+}
+
+type Repository struct {
+ ID uuid.UUID `json:"id"`
+ WorkspaceID uuid.UUID `json:"workspaceId"`
+ ProviderType string `json:"providerType"`
+ Name string `json:"name"`
+ RemoteURL string `json:"remoteUrl"`
+ NormalizedURL string `json:"normalizedUrl"`
+ ConfiguredRef string `json:"ref"`
+ ConfigVersion int64 `json:"configVersion"`
+ Status string `json:"status"`
+ CurrentSnapshotID *uuid.UUID `json:"currentSnapshotId,omitempty"`
+ CurrentSnapshot *Snapshot `json:"currentSnapshot,omitempty"`
+ MirrorSizeBytes int64 `json:"mirrorSizeBytes"`
+ LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
+ LastErrorCode string `json:"lastErrorCode,omitempty"`
+ LastErrorMessage string `json:"lastErrorMessage,omitempty"`
+ ArchivedAt *time.Time `json:"archivedAt,omitempty"`
+ DeletedAt *time.Time `json:"deletedAt,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+type Operation struct {
+ ID uuid.UUID `json:"id"`
+ RepositoryID uuid.UUID `json:"repositoryId"`
+ ActorUserID uuid.UUID `json:"actorUserId"`
+ ProviderConnectionID *uuid.UUID `json:"providerConnectionId,omitempty"`
+ CredentialVersion int64 `json:"credentialVersion,omitempty"`
+ RepositoryVersion int64 `json:"repositoryVersion"`
+ Kind string `json:"kind"`
+ RequestedProviderType string `json:"requestedProviderType"`
+ RequestedRemoteURL string `json:"requestedRemoteUrl"`
+ RequestedNormalizedURL string `json:"requestedNormalizedUrl"`
+ RequestedRef string `json:"requestedRef"`
+ PreviousProviderType string `json:"-"`
+ PreviousRemoteURL string `json:"-"`
+ PreviousNormalizedURL string `json:"-"`
+ PreviousRef string `json:"-"`
+ Status string `json:"status"`
+ Outcome string `json:"outcome,omitempty"`
+ ResolvedCommitSHA string `json:"resolvedCommitSha,omitempty"`
+ SnapshotID *uuid.UUID `json:"snapshotId,omitempty"`
+ WorkflowRunID string `json:"workflowRunId,omitempty"`
+ ErrorMessage string `json:"errorMessage,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ StartedAt *time.Time `json:"startedAt,omitempty"`
+ FinishedAt *time.Time `json:"finishedAt,omitempty"`
+}
+
+type WorkItem struct {
+ Repository Repository
+ Operation Operation
+}
diff --git a/internal/repository/biz/service.go b/internal/repository/biz/service.go
new file mode 100644
index 0000000..8a63511
--- /dev/null
+++ b/internal/repository/biz/service.go
@@ -0,0 +1,290 @@
+package biz
+
+import (
+ "context"
+ "strings"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+)
+
+type Authorizer interface {
+ Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error)
+}
+
+type ProviderConnections interface {
+ Get(context.Context, uuid.UUID, uuid.UUID) (identity.ProviderConnection, error)
+}
+
+type Service struct {
+ store Store
+ authorizer Authorizer
+ providers ProviderConnections
+ canceller Canceller
+ git gitrepo.Manager
+}
+
+func NewService(store Store, authorizer Authorizer, providers ProviderConnections, canceller Canceller, git gitrepo.Manager) *Service {
+ return &Service{store: store, authorizer: authorizer, providers: providers, canceller: canceller, git: git}
+}
+
+type CreateInput struct {
+ Name string
+ RemoteURL string
+ Ref string
+ ProviderConnectionID *uuid.UUID
+}
+
+type UpdateInput struct {
+ Name string
+ RemoteURL string
+ Ref string
+ ProviderConnectionID *uuid.UUID
+}
+
+func (s *Service) Create(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, input CreateInput) (Repository, Operation, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Repository{}, Operation{}, err
+ }
+ remote, connection, err := s.resolveRemote(ctx, actor.UserID, input.ProviderConnectionID, input.RemoteURL)
+ if err != nil {
+ return Repository{}, Operation{}, err
+ }
+ name := strings.TrimSpace(input.Name)
+ if name == "" {
+ name = remote.Name
+ }
+ configuredRef := strings.TrimSpace(input.Ref)
+ if configuredRef == "" {
+ return Repository{}, Operation{}, fault.New(fault.Invalid, "repository.ref_required", "Repository ref is required")
+ }
+ repositoryID, operationID := uuid.New(), uuid.New()
+ repository := Repository{ID: repositoryID, WorkspaceID: workspaceID, ProviderType: remote.ProviderType, Name: name, RemoteURL: remote.URL, NormalizedURL: remote.Normalized, ConfiguredRef: configuredRef, Status: "provisioning"}
+ operation := sourceOperation(actor, repository, connection, remote, configuredRef, "provision", 1)
+ operation.ID = operationID
+ repository, operation, err = s.store.Create(ctx, repository, operation)
+ if err != nil {
+ return Repository{}, Operation{}, err
+ }
+ return repository, operation, nil
+}
+
+func (s *Service) List(ctx context.Context, actor auth.Actor, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[Repository], error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return pagination.Page[Repository]{}, err
+ }
+
+ return s.store.List(ctx, workspaceID, page)
+}
+
+func (s *Service) Get(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID) (Repository, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Repository{}, err
+ }
+
+ return s.store.Get(ctx, workspaceID, repositoryID)
+}
+
+func (s *Service) Snapshots(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[Snapshot], error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return pagination.Page[Snapshot]{}, err
+ }
+ if _, err := s.store.Get(ctx, workspaceID, repositoryID); err != nil {
+ return pagination.Page[Snapshot]{}, err
+ }
+
+ return s.store.Snapshots(ctx, workspaceID, repositoryID, page)
+}
+
+func (s *Service) Snapshot(ctx context.Context, actor auth.Actor, workspaceID, repositoryID, snapshotID uuid.UUID) (Snapshot, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Snapshot{}, err
+ }
+
+ return s.store.Snapshot(ctx, workspaceID, repositoryID, snapshotID)
+}
+
+func (s *Service) Update(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, input UpdateInput) (Repository, *Operation, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Repository{}, nil, err
+ }
+ repository, err := s.store.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ return Repository{}, nil, err
+ }
+ name := strings.TrimSpace(input.Name)
+ if name == "" {
+ name = repository.Name
+ }
+ configuredRef := strings.TrimSpace(input.Ref)
+ if configuredRef == "" {
+ configuredRef = repository.ConfiguredRef
+ }
+ remoteURL := strings.TrimSpace(input.RemoteURL)
+ if remoteURL == "" {
+ remoteURL = repository.RemoteURL
+ }
+ sourceChanged := configuredRef != repository.ConfiguredRef || remoteURL != repository.RemoteURL
+ if !sourceChanged {
+ repository, err = s.store.Update(ctx, workspaceID, repositoryID, name)
+ if err != nil {
+ return Repository{}, nil, err
+ }
+
+ return repository, nil, nil
+ }
+ if repository.ArchivedAt != nil || repository.Status == "deleting" || repository.Status == "provisioning" || repository.Status == "syncing" {
+ return Repository{}, nil, fault.New(fault.Conflict, "repository.update_unavailable", "Repository source is not available for update")
+ }
+ remote, connection, err := s.resolveRemote(ctx, actor.UserID, input.ProviderConnectionID, remoteURL)
+ if err != nil {
+ return Repository{}, nil, err
+ }
+ operation := sourceOperation(actor, repository, connection, remote, configuredRef, "update", repository.ConfigVersion+1)
+ repository, operation, err = s.store.UpdateWithOperation(ctx, workspaceID, repositoryID, name, operation)
+ if err != nil {
+ return Repository{}, nil, err
+ }
+
+ return repository, &operation, nil
+}
+
+func (s *Service) Refresh(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, connectionID *uuid.UUID) (Operation, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Operation{}, err
+ }
+ repository, err := s.store.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ return Operation{}, err
+ }
+ if repository.ArchivedAt != nil || repository.Status == "deleting" || repository.Status == "provisioning" || repository.Status == "syncing" {
+ return Operation{}, fault.New(fault.Conflict, "repository.refresh_unavailable", "Repository is not available for refresh")
+ }
+
+ return s.newRemoteOperation(ctx, actor, repository, connectionID, "refresh")
+}
+
+func (s *Service) newRemoteOperation(ctx context.Context, actor auth.Actor, repository Repository, connectionID *uuid.UUID, kind string) (Operation, error) {
+ remote, connection, err := s.resolveRemote(ctx, actor.UserID, connectionID, repository.RemoteURL)
+ if err != nil {
+ return Operation{}, err
+ }
+ if remote.ProviderType != repository.ProviderType || remote.Normalized != repository.NormalizedURL {
+ return Operation{}, fault.New(fault.Invalid, "provider.connection_mismatch", "The selected provider connection does not match the repository")
+ }
+ operation := sourceOperation(actor, repository, connection, remote, repository.ConfiguredRef, kind, repository.ConfigVersion)
+
+ return s.store.CreateOperation(ctx, repository.WorkspaceID, operation)
+}
+
+func (s *Service) resolveRemote(ctx context.Context, userID uuid.UUID, connectionID *uuid.UUID, remoteURL string) (gitrepo.Remote, *identity.ProviderConnection, error) {
+ if connectionID == nil {
+ remote, err := gitrepo.NormalizePublicRemote(remoteURL)
+ if err != nil {
+ return gitrepo.Remote{}, nil, fault.Wrap(fault.Invalid, "repository.remote_invalid", "Repository URL is invalid or requires a provider connection", err)
+ }
+
+ return remote, nil, nil
+ }
+
+ connection, err := s.providers.Get(ctx, userID, *connectionID)
+ if err != nil {
+ return gitrepo.Remote{}, nil, fault.New(fault.Invalid, "provider.connection_unavailable", "Provider connection is not available to the current user")
+ }
+ if connection.Status != "active" {
+ return gitrepo.Remote{}, nil, fault.New(fault.Invalid, "provider.connection_inactive", "Provider connection is not active")
+ }
+ remote, err := gitrepo.NormalizeRemote(connection.ProviderType, connection.BaseURL, remoteURL)
+ if err != nil {
+ return gitrepo.Remote{}, nil, fault.Wrap(fault.Invalid, "repository.remote_invalid", "Repository URL is invalid", err)
+ }
+
+ return remote, &connection, nil
+}
+
+func sourceOperation(actor auth.Actor, repository Repository, connection *identity.ProviderConnection, remote gitrepo.Remote, requestedRef, kind string, repositoryVersion int64) Operation {
+ operation := Operation{
+ ID: uuid.New(), RepositoryID: repository.ID, ActorUserID: actor.UserID,
+ RepositoryVersion: repositoryVersion, Kind: kind,
+ RequestedProviderType: remote.ProviderType, RequestedRemoteURL: remote.URL,
+ RequestedNormalizedURL: remote.Normalized, RequestedRef: requestedRef,
+ PreviousProviderType: repository.ProviderType, PreviousRemoteURL: repository.RemoteURL,
+ PreviousNormalizedURL: repository.NormalizedURL, PreviousRef: repository.ConfiguredRef,
+ Status: "queued",
+ }
+ if connection != nil {
+ connectionID := connection.ID
+ operation.ProviderConnectionID = &connectionID
+ operation.CredentialVersion = connection.CredentialVersion
+ }
+
+ return operation
+}
+
+func (s *Service) Delete(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID) (Operation, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Operation{}, err
+ }
+ repository, err := s.store.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ return Operation{}, err
+ }
+ if repository.Status == "provisioning" || repository.Status == "syncing" || repository.Status == "deleting" {
+ return Operation{}, fault.New(fault.Conflict, "repository.deletion_unavailable", "Repository cannot be deleted while an operation is active")
+ }
+ operation := Operation{
+ ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: actor.UserID,
+ RepositoryVersion: repository.ConfigVersion, Kind: "purge",
+ RequestedProviderType: repository.ProviderType, RequestedRemoteURL: repository.RemoteURL,
+ RequestedNormalizedURL: repository.NormalizedURL, RequestedRef: repository.ConfiguredRef,
+ PreviousProviderType: repository.ProviderType, PreviousRemoteURL: repository.RemoteURL,
+ PreviousNormalizedURL: repository.NormalizedURL, PreviousRef: repository.ConfiguredRef,
+ Status: "queued",
+ }
+ _, operation, err = s.store.RequestDeletion(ctx, workspaceID, repositoryID, operation)
+ if err != nil {
+ return Operation{}, err
+ }
+ return operation, nil
+}
+
+func (s *Service) Archive(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, archived bool) (Repository, error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return Repository{}, err
+ }
+
+ return s.store.Archive(ctx, workspaceID, repositoryID, archived)
+}
+
+func (s *Service) Operations(ctx context.Context, actor auth.Actor, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[Operation], error) {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return pagination.Page[Operation]{}, err
+ }
+ if _, err := s.store.Get(ctx, workspaceID, repositoryID); err != nil {
+ return pagination.Page[Operation]{}, err
+ }
+
+ return s.store.ListOperations(ctx, repositoryID, page)
+}
+
+func (s *Service) Cancel(ctx context.Context, actor auth.Actor, workspaceID, repositoryID, operationID uuid.UUID) error {
+ if _, err := s.authorizer.Membership(ctx, actor, workspaceID, "member"); err != nil {
+ return err
+ }
+ if _, err := s.store.Get(ctx, workspaceID, repositoryID); err != nil {
+ return err
+ }
+ operation, ok, err := s.store.CancelOperation(ctx, workspaceID, actor.UserID, repositoryID, operationID)
+ if err != nil || !ok {
+ return fault.New(fault.Conflict, "repository.operation_not_cancellable", "Repository operation cannot be cancelled")
+ }
+ if operation.WorkflowRunID != "" {
+ _ = s.canceller.CancelRepository(ctx, operation.WorkflowRunID)
+ }
+
+ return nil
+}
diff --git a/internal/repository/biz/service_test.go b/internal/repository/biz/service_test.go
new file mode 100644
index 0000000..952685c
--- /dev/null
+++ b/internal/repository/biz/service_test.go
@@ -0,0 +1,306 @@
+package biz
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+)
+
+type repositoryAuthorizer struct{}
+
+func (repositoryAuthorizer) Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error) {
+ return identity.Membership{Role: "member"}, nil
+}
+
+type providerConnections struct {
+ connection identity.ProviderConnection
+ err error
+ calls int
+ userID uuid.UUID
+}
+
+func (p *providerConnections) Get(_ context.Context, userID, _ uuid.UUID) (identity.ProviderConnection, error) {
+ p.calls++
+ p.userID = userID
+ return p.connection, p.err
+}
+
+type repositoryStore struct {
+ repository Repository
+ operation Operation
+ getErr error
+ updated bool
+ updatedWithOperation bool
+ deletionRequested bool
+ cancelled bool
+}
+
+func (s *repositoryStore) Create(_ context.Context, repository Repository, operation Operation) (Repository, Operation, error) {
+ s.repository = repository
+ s.operation = operation
+
+ return repository, operation, nil
+}
+func (s *repositoryStore) Get(context.Context, uuid.UUID, uuid.UUID) (Repository, error) {
+ return s.repository, s.getErr
+}
+func (s *repositoryStore) List(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Repository], error) {
+ return pagination.Page[Repository]{Items: []Repository{s.repository}}, nil
+}
+func (s *repositoryStore) Update(_ context.Context, _, _ uuid.UUID, name string) (Repository, error) {
+ s.updated = true
+ s.repository.Name = name
+ return s.repository, nil
+}
+func (s *repositoryStore) UpdateWithOperation(_ context.Context, _, _ uuid.UUID, name string, operation Operation) (Repository, Operation, error) {
+ s.updatedWithOperation = true
+ s.repository.Name = name
+ s.repository.ProviderType = operation.RequestedProviderType
+ s.repository.RemoteURL = operation.RequestedRemoteURL
+ s.repository.NormalizedURL = operation.RequestedNormalizedURL
+ s.repository.ConfiguredRef = operation.RequestedRef
+ s.operation = operation
+ return s.repository, operation, nil
+}
+func (s *repositoryStore) Archive(context.Context, uuid.UUID, uuid.UUID, bool) (Repository, error) {
+ return s.repository, nil
+}
+func (s *repositoryStore) RequestDeletion(_ context.Context, _, _ uuid.UUID, operation Operation) (Repository, Operation, error) {
+ s.deletionRequested = true
+ s.operation = operation
+ return s.repository, operation, nil
+}
+func (s *repositoryStore) Delete(context.Context, uuid.UUID, uuid.UUID) error { return nil }
+func (s *repositoryStore) CreateOperation(_ context.Context, _ uuid.UUID, operation Operation) (Operation, error) {
+ s.operation = operation
+ return operation, nil
+}
+func (s *repositoryStore) ListOperations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Operation], error) {
+ return pagination.Page[Operation]{Items: []Operation{s.operation}}, nil
+}
+func (s *repositoryStore) Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (Snapshot, error) {
+ return Snapshot{}, nil
+}
+func (s *repositoryStore) Snapshots(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Snapshot], error) {
+ return pagination.Page[Snapshot]{}, nil
+}
+func (s *repositoryStore) GetWorkItem(context.Context, uuid.UUID) (WorkItem, error) {
+ return WorkItem{Repository: s.repository, Operation: s.operation}, nil
+}
+func (s *repositoryStore) StartOperation(context.Context, uuid.UUID) (Operation, error) {
+ return s.operation, nil
+}
+func (s *repositoryStore) CompleteSync(context.Context, WorkItem, uuid.UUID, gitrepo.Snapshot) (Operation, error) {
+ return s.operation, nil
+}
+func (*repositoryStore) HasRetainedAnalysis(context.Context, uuid.UUID) (bool, error) {
+ return false, nil
+}
+func (*repositoryStore) CompleteRetainedDeletion(context.Context, WorkItem) error { return nil }
+func (s *repositoryStore) CompletePurge(context.Context, WorkItem) error { return nil }
+func (s *repositoryStore) FailOperation(context.Context, WorkItem, Failure) error { return nil }
+func (s *repositoryStore) CancelOperation(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) (Operation, bool, error) {
+ s.cancelled = true
+ return s.operation, true, nil
+}
+
+type repositoryCanceller struct {
+ called *string
+}
+
+func (c repositoryCanceller) CancelRepository(_ context.Context, workflowRunID string) error {
+ if c.called != nil {
+ *c.called = workflowRunID
+ }
+
+ return nil
+}
+
+func repositoryConnectionID(value uuid.UUID) *uuid.UUID {
+ return &value
+}
+
+func TestCreatePublicRepositoryWithoutProviderConnection(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ workspaceID := uuid.New()
+ store := &repositoryStore{}
+ providers := &providerConnections{}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ created, operation, err := service.Create(context.Background(), actor, workspaceID, CreateInput{
+ RemoteURL: "https://github.com/example/public-repo",
+ Ref: "main",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if providers.calls != 0 || operation.ProviderConnectionID != nil || operation.CredentialVersion != 0 {
+ t.Fatalf("public repository loaded a provider connection: operation=%#v calls=%d", operation, providers.calls)
+ }
+ if created.ProviderType != "github" || created.Name != "public-repo" || created.RemoteURL != "https://github.com/example/public-repo.git" {
+ t.Fatalf("unexpected public repository: %#v", created)
+ }
+}
+
+func TestUpdatePublicRepositorySourceWithoutProviderConnection(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 4, Status: "ready"}
+ store := &repositoryStore{repository: repository}
+ providers := &providerConnections{}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ updated, operation, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{
+ RemoteURL: "https://github.com/example/next-repo",
+ Ref: "release",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if providers.calls != 0 || operation == nil || operation.ProviderConnectionID != nil {
+ t.Fatalf("public source update loaded a provider connection: operation=%#v calls=%d", operation, providers.calls)
+ }
+ if updated.RemoteURL != "https://github.com/example/next-repo.git" || updated.ConfiguredRef != "release" {
+ t.Fatalf("unexpected public source update: %#v", updated)
+ }
+}
+
+func TestUpdateSourceDoesNotMutateRepositoryWithoutPersonalConnection(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 6, Status: "ready"}
+ store := &repositoryStore{repository: repository}
+ providers := &providerConnections{err: errors.New("not found")}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ if _, _, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{Name: repository.Name, Ref: "release", ProviderConnectionID: repositoryConnectionID(uuid.New())}); err == nil {
+ t.Fatal("expected update to reject an unavailable connection")
+ }
+ if store.updated || store.updatedWithOperation || store.repository.ConfiguredRef != "main" {
+ t.Fatalf("repository mutated before credential validation: %#v", store)
+ }
+ if providers.userID != actor.UserID {
+ t.Fatalf("provider lookup used user %s, want %s", providers.userID, actor.UserID)
+ }
+}
+
+func TestUpdateSourceCreatesOperationWithActorCredentialVersion(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ connectionID := uuid.New()
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 6, Status: "ready"}
+ store := &repositoryStore{repository: repository}
+ providers := &providerConnections{connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", BaseURL: "https://github.com", CredentialVersion: 7, Status: "active"}}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ updated, operation, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{Name: repository.Name, RemoteURL: "https://github.com/example/new-repo", Ref: "release", ProviderConnectionID: repositoryConnectionID(connectionID)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !store.updatedWithOperation || updated.ConfiguredRef != "release" || operation == nil {
+ t.Fatalf("unexpected update result: %#v %#v", updated, operation)
+ }
+ if operation.ActorUserID != actor.UserID || operation.ProviderConnectionID == nil || *operation.ProviderConnectionID != connectionID || operation.CredentialVersion != 7 || operation.RepositoryVersion != 7 {
+ t.Fatalf("operation did not freeze actor credential: %#v", operation)
+ }
+ if operation.Kind != "update" || operation.RequestedRemoteURL != "https://github.com/example/new-repo.git" || operation.RequestedRef != "release" || operation.PreviousRemoteURL != repository.RemoteURL || operation.PreviousRef != "main" {
+ t.Fatalf("operation did not freeze source transition: %#v", operation)
+ }
+}
+
+func TestDeleteDoesNotLoadProviderCredential(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github"}
+ store := &repositoryStore{repository: repository}
+ providers := &providerConnections{}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ operation, err := service.Delete(context.Background(), actor, repository.WorkspaceID, repository.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !store.deletionRequested || providers.calls != 0 || operation.Kind != "purge" || operation.ProviderConnectionID != nil {
+ t.Fatalf("unexpected local deletion operation: %#v provider calls=%d", operation, providers.calls)
+ }
+}
+
+func TestRefreshPublicRepositoryWithoutProviderConnection(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", RemoteURL: "https://github.com/example/public-repo.git", NormalizedURL: "github.com/example/public-repo", ConfiguredRef: "main", ConfigVersion: 2, Status: "ready"}
+ store := &repositoryStore{repository: repository}
+ providers := &providerConnections{}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ operation, err := service.Refresh(context.Background(), actor, repository.WorkspaceID, repository.ID, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if providers.calls != 0 || operation.ProviderConnectionID != nil || operation.Kind != "refresh" {
+ t.Fatalf("unexpected anonymous refresh: operation=%#v calls=%d", operation, providers.calls)
+ }
+}
+
+func TestDeleteRejectsRepositoryWithActiveOperation(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Status: "syncing"}
+ store := &repositoryStore{repository: repository}
+ service := NewService(store, repositoryAuthorizer{}, &providerConnections{}, repositoryCanceller{}, nil)
+
+ if _, err := service.Delete(context.Background(), actor, repository.WorkspaceID, repository.ID); err == nil {
+ t.Fatal("expected deletion to reject a repository with an active operation")
+ }
+ if store.deletionRequested {
+ t.Fatal("deletion was persisted while a repository operation was active")
+ }
+}
+
+func TestUpdateSourceRejectsInactiveProviderConnection(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ProviderType: "github", Name: "repo", RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo", ConfiguredRef: "main", ConfigVersion: 1, Status: "ready"}
+ store := &repositoryStore{repository: repository}
+ providers := &providerConnections{connection: identity.ProviderConnection{ID: uuid.New(), ProviderType: "github", BaseURL: "https://github.com", Status: "invalid"}}
+ service := NewService(store, repositoryAuthorizer{}, providers, repositoryCanceller{}, nil)
+
+ if _, _, err := service.Update(context.Background(), actor, repository.WorkspaceID, repository.ID, UpdateInput{Ref: "release", ProviderConnectionID: repositoryConnectionID(providers.connection.ID)}); err == nil {
+ t.Fatal("expected update to reject an inactive provider connection")
+ }
+ if store.updatedWithOperation {
+ t.Fatal("source update was persisted with an inactive provider connection")
+ }
+}
+
+func TestCancelRejectsRepositoryOutsideWorkspace(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New()}
+ operation := Operation{ID: uuid.New(), RepositoryID: repository.ID}
+ want := errors.New("repository not found")
+ store := &repositoryStore{repository: repository, operation: operation, getErr: want}
+ service := NewService(store, repositoryAuthorizer{}, &providerConnections{}, repositoryCanceller{}, nil)
+
+ err := service.Cancel(context.Background(), actor, uuid.New(), repository.ID, operation.ID)
+ if !errors.Is(err, want) {
+ t.Fatalf("Cancel() error = %v, want %v", err, want)
+ }
+ if store.cancelled {
+ t.Fatal("operation from a repository outside the workspace was cancelled")
+ }
+}
+
+func TestCancelUsesWorkflowIDReturnedByAtomicCancellation(t *testing.T) {
+ actor := auth.Actor{UserID: uuid.New()}
+ repository := Repository{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "syncing"}
+ operation := Operation{ID: uuid.New(), RepositoryID: repository.ID, WorkflowRunID: "current-hatchet-run"}
+ store := &repositoryStore{repository: repository, operation: operation}
+ var cancelled string
+ service := NewService(store, repositoryAuthorizer{}, &providerConnections{}, repositoryCanceller{called: &cancelled}, nil)
+
+ if err := service.Cancel(context.Background(), actor, repository.WorkspaceID, repository.ID, operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if cancelled != operation.WorkflowRunID {
+ t.Fatalf("cancelled workflow = %q, want %q", cancelled, operation.WorkflowRunID)
+ }
+}
diff --git a/internal/repository/biz/store.go b/internal/repository/biz/store.go
new file mode 100644
index 0000000..349b3ef
--- /dev/null
+++ b/internal/repository/biz/store.go
@@ -0,0 +1,36 @@
+package biz
+
+import (
+ "context"
+
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+)
+
+type Store interface {
+ Create(context.Context, Repository, Operation) (Repository, Operation, error)
+ Get(context.Context, uuid.UUID, uuid.UUID) (Repository, error)
+ List(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Repository], error)
+ Update(context.Context, uuid.UUID, uuid.UUID, string) (Repository, error)
+ UpdateWithOperation(context.Context, uuid.UUID, uuid.UUID, string, Operation) (Repository, Operation, error)
+ Archive(context.Context, uuid.UUID, uuid.UUID, bool) (Repository, error)
+ RequestDeletion(context.Context, uuid.UUID, uuid.UUID, Operation) (Repository, Operation, error)
+ Delete(context.Context, uuid.UUID, uuid.UUID) error
+ CreateOperation(context.Context, uuid.UUID, Operation) (Operation, error)
+ ListOperations(context.Context, uuid.UUID, pagination.Request) (pagination.Page[Operation], error)
+ Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (Snapshot, error)
+ Snapshots(context.Context, uuid.UUID, uuid.UUID, pagination.Request) (pagination.Page[Snapshot], error)
+ GetWorkItem(context.Context, uuid.UUID) (WorkItem, error)
+ StartOperation(context.Context, uuid.UUID) (Operation, error)
+ CompleteSync(context.Context, WorkItem, uuid.UUID, gitrepo.Snapshot) (Operation, error)
+ HasRetainedAnalysis(context.Context, uuid.UUID) (bool, error)
+ CompleteRetainedDeletion(context.Context, WorkItem) error
+ CompletePurge(context.Context, WorkItem) error
+ FailOperation(context.Context, WorkItem, Failure) error
+ CancelOperation(context.Context, uuid.UUID, uuid.UUID, uuid.UUID, uuid.UUID) (Operation, bool, error)
+}
+
+type Canceller interface {
+ CancelRepository(context.Context, string) error
+}
diff --git a/internal/repository/channel.go b/internal/repository/channel.go
deleted file mode 100644
index 1380733..0000000
--- a/internal/repository/channel.go
+++ /dev/null
@@ -1,41 +0,0 @@
-package repository
-
-import (
- "context"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
-)
-
-type MessageCursor = TimeCursor
-type ChannelMutationStore interface {
- CreateChannel(ctx context.Context, instance model.ChannelInstance) (model.ChannelInstance, error)
- UpdateChannel(ctx context.Context, instance model.ChannelInstance, expectedVersion int64) (model.ChannelInstance, error)
- SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error)
- SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error)
- AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error
- AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error
-}
-type ChannelStore interface {
- MutationStore
- WithinChannelTx(ctx context.Context, fn func(ChannelMutationStore) error) error
- CreateChannel(ctx context.Context, instance model.ChannelInstance) (model.ChannelInstance, error)
- ListChannels(ctx context.Context, workspaceID uuid.UUID) ([]model.ChannelInstance, error)
- GetChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error)
- UpdateChannel(ctx context.Context, instance model.ChannelInstance, expectedVersion int64) (model.ChannelInstance, error)
- SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error)
- SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error)
- ListEnabledChannels(ctx context.Context) ([]model.ChannelInstance, error)
- AcquireChannelLease(ctx context.Context, instanceID uuid.UUID, owner string, until time.Time) (model.ChannelLease, error)
- RenewChannelLease(ctx context.Context, lease model.ChannelLease, until time.Time) error
- ReleaseChannelLease(ctx context.Context, lease model.ChannelLease) error
- SetChannelStatus(ctx context.Context, status model.ChannelRuntimeStatus) error
- GetChannelStatus(ctx context.Context, instanceID uuid.UUID) (model.ChannelRuntimeStatus, error)
- AcceptInbound(ctx context.Context, instance model.ChannelInstance, message channelcore.InboundMessage) (bool, error)
- ListMessages(ctx context.Context, workspaceID uuid.UUID, channelID, conversationID *uuid.UUID, cursor MessageCursor, pageSize int32) ([]model.IMMessage, error)
- ListConversations(ctx context.Context, workspaceID uuid.UUID, pageSize int32) ([]model.IMConversation, error)
- AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error
- AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error
-}
diff --git a/internal/repository/data/options.go b/internal/repository/data/options.go
new file mode 100644
index 0000000..598f7d6
--- /dev/null
+++ b/internal/repository/data/options.go
@@ -0,0 +1,11 @@
+package data
+
+type Option func(*Store)
+
+func WithMaxRepositoriesPerWorkspace(limit int64) Option {
+ return func(store *Store) {
+ if limit > 0 {
+ store.maxPerWorkspace = limit
+ }
+ }
+}
diff --git a/internal/repository/data/quota_integration_test.go b/internal/repository/data/quota_integration_test.go
new file mode 100644
index 0000000..b8e5b0b
--- /dev/null
+++ b/internal/repository/data/quota_integration_test.go
@@ -0,0 +1,65 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+)
+
+func TestRepositoryWorkspaceQuotaIsHardLimit(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+ manager, err := gitrepo.NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewStore(pool, manager, WithMaxRepositoriesPerWorkspace(1))
+ userID, workspaceID := uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Repository quota',$2,$3)`, workspaceID, "repository-quota-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+
+ create := func(name string) error {
+ repositoryID := uuid.New()
+ item := repository.Repository{
+ ID: repositoryID, WorkspaceID: workspaceID, ProviderType: "github", Name: name,
+ RemoteURL: "https://github.com/example/" + name + ".git", NormalizedURL: "github.com/example/" + name,
+ ConfiguredRef: "main", Status: "provisioning",
+ }
+ operation := repository.Operation{
+ ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID,
+ RepositoryVersion: 1, Kind: "provision",
+ RequestedProviderType: "github", RequestedRemoteURL: item.RemoteURL,
+ RequestedNormalizedURL: item.NormalizedURL, RequestedRef: "main",
+ PreviousProviderType: "github", PreviousRemoteURL: item.RemoteURL,
+ PreviousNormalizedURL: item.NormalizedURL, PreviousRef: "main",
+ Status: "queued",
+ }
+ _, _, err := store.Create(ctx, item, operation)
+
+ return err
+ }
+ if err := create("first"); err != nil {
+ t.Fatal(err)
+ }
+ if err := create("second"); err == nil {
+ t.Fatal("expected repository quota error")
+ } else if problem, ok := fault.From(err); !ok || problem.Code() != "repository.workspace_quota_exceeded" {
+ t.Fatalf("quota error = %v", err)
+ }
+}
diff --git a/internal/repository/data/store.go b/internal/repository/data/store.go
new file mode 100644
index 0000000..37a25a9
--- /dev/null
+++ b/internal/repository/data/store.go
@@ -0,0 +1,639 @@
+package data
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/data/pagecursor"
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ workflow "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ pool *pgxpool.Pool
+ queries *sqlc.Queries
+ git gitrepo.Manager
+ maxPerWorkspace int64
+}
+
+func NewStore(pool *pgxpool.Pool, git gitrepo.Manager, options ...Option) *Store {
+ store := &Store{pool: pool, queries: sqlc.New(pool), git: git, maxPerWorkspace: 100}
+ for _, option := range options {
+ option(store)
+ }
+
+ return store
+}
+
+func (s *Store) Create(ctx context.Context, item repository.Repository, operation repository.Operation) (repository.Repository, repository.Operation, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ if _, err = q.LockWorkspaceQuota(ctx, item.WorkspaceID); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ count, err := q.CountManagedRepositories(ctx, item.WorkspaceID)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if count >= s.maxPerWorkspace {
+ return repository.Repository{}, repository.Operation{}, fault.New(fault.Conflict, "repository.workspace_quota_exceeded", "Workspace repository quota has been reached")
+ }
+ row, err := q.CreateRepository(ctx, sqlc.CreateRepositoryParams{ID: item.ID, WorkspaceID: item.WorkspaceID, ProviderType: item.ProviderType, Name: item.Name, RemoteUrl: item.RemoteURL, NormalizedUrl: item.NormalizedURL, ConfiguredRef: item.ConfiguredRef, GitPath: s.git.Path(item.ID), CreatedBy: operation.ActorUserID})
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if _, err = q.ReserveRepositorySource(ctx, sqlc.ReserveRepositorySourceParams{
+ WorkspaceID: item.WorkspaceID, NormalizedUrl: item.NormalizedURL, RepositoryID: item.ID,
+ }); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return repository.Repository{}, repository.Operation{}, sourceConflict()
+ }
+
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ op, err := q.CreateRepositoryOperation(ctx, operationParams(operation))
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ dispatch, err := repositoryDispatch(operation.ID, item.ID)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if err = recordRepositoryOperation(ctx, q, item.WorkspaceID, operation, audit.ActionRepositoryProvisionRequested); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ err = tx.Commit(ctx)
+
+ return mapRepository(row), mapOperation(op), err
+}
+
+func (s *Store) Get(ctx context.Context, workspaceID, id uuid.UUID) (repository.Repository, error) {
+ row, err := s.queries.GetRepository(ctx, sqlc.GetRepositoryParams{ID: id, WorkspaceID: workspaceID})
+ if err != nil {
+ return repository.Repository{}, err
+ }
+ item := mapRepository(row)
+ if row.CurrentSnapshotID.Valid {
+ snapshot, snapshotErr := s.queries.GetSnapshot(ctx, sqlc.GetSnapshotParams{ID: row.CurrentSnapshotID.UUID, RepositoryID: id})
+ if snapshotErr != nil {
+ return repository.Repository{}, snapshotErr
+ }
+ mapped := mapSnapshot(snapshot)
+ item.CurrentSnapshot = &mapped
+ }
+
+ return item, nil
+}
+
+func (s *Store) List(ctx context.Context, workspaceID uuid.UUID, page pagination.Request) (pagination.Page[repository.Repository], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListRepositories(ctx, sqlc.ListRepositoriesParams{
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[repository.Repository]{}, err
+ }
+ items := make([]repository.Repository, 0, len(rows))
+ for _, row := range rows {
+ item := repository.Repository{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, ProviderType: row.ProviderType, Name: row.Name,
+ RemoteURL: row.RemoteUrl, NormalizedURL: row.NormalizedUrl, ConfiguredRef: row.ConfiguredRef,
+ ConfigVersion: row.ConfigVersion, Status: row.Status, MirrorSizeBytes: row.MirrorSizeBytes,
+ LastSyncAt: optionalTime(row.LastSyncAt), LastErrorCode: row.LastErrorCode.String, LastErrorMessage: row.LastErrorMessage.String,
+ ArchivedAt: optionalTime(row.ArchivedAt), DeletedAt: optionalTime(row.DeletedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
+ }
+ if row.CurrentSnapshotID.Valid {
+ id := row.CurrentSnapshotID.UUID
+ item.CurrentSnapshotID = &id
+ snapshot := repository.Snapshot{ID: id, RepositoryID: row.ID, CommitSHA: row.CurrentCommitSha.String, SourceRef: row.CurrentSourceRef.String, AuthorName: row.CurrentAuthorName.String, AuthoredAt: optionalTime(row.CurrentAuthoredAt), Title: row.CurrentTitle.String, SourceState: row.CurrentSourceState.String, CreatedAt: row.SnapshotCreatedAt.Time}
+ item.CurrentSnapshot = &snapshot
+ }
+ items = append(items, item)
+ }
+
+ return pagination.Build(items, page.Limit, func(item repository.Repository) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) Update(ctx context.Context, workspaceID, id uuid.UUID, name string) (repository.Repository, error) {
+ row, err := s.queries.UpdateRepository(ctx, sqlc.UpdateRepositoryParams{ID: id, WorkspaceID: workspaceID, Name: name})
+ return mapRepository(row), err
+}
+
+func (s *Store) UpdateWithOperation(ctx context.Context, workspaceID, id uuid.UUID, name string, operation repository.Operation) (repository.Repository, repository.Operation, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ if _, err = q.ReserveRepositorySource(ctx, sqlc.ReserveRepositorySourceParams{
+ WorkspaceID: workspaceID, NormalizedUrl: operation.RequestedNormalizedURL, RepositoryID: id,
+ }); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return repository.Repository{}, repository.Operation{}, sourceConflict()
+ }
+
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ row, err := q.StageRepositoryUpdate(ctx, sqlc.StageRepositoryUpdateParams{
+ ID: id, WorkspaceID: workspaceID, Name: name,
+ ProviderType: operation.RequestedProviderType, RemoteUrl: operation.RequestedRemoteURL,
+ NormalizedUrl: operation.RequestedNormalizedURL, ConfiguredRef: operation.RequestedRef,
+ ConfigVersion: operation.RepositoryVersion - 1,
+ })
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return repository.Repository{}, repository.Operation{}, stateConflict()
+ }
+
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ op, err := q.CreateRepositoryOperation(ctx, operationParams(operation))
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ dispatch, err := repositoryDispatch(operation.ID, operation.RepositoryID)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if err = recordRepositoryOperation(ctx, q, workspaceID, operation, audit.ActionRepositorySourceUpdateRequested); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+
+ return mapRepository(row), mapOperation(op), nil
+}
+
+func (s *Store) Archive(ctx context.Context, workspaceID, id uuid.UUID, archived bool) (repository.Repository, error) {
+ var row sqlc.Repository
+ var err error
+ if archived {
+ row, err = s.queries.ArchiveRepository(ctx, sqlc.ArchiveRepositoryParams{ID: id, WorkspaceID: workspaceID})
+ } else {
+ row, err = s.queries.RestoreRepository(ctx, sqlc.RestoreRepositoryParams{ID: id, WorkspaceID: workspaceID})
+ }
+
+ return mapRepository(row), err
+}
+
+func (s *Store) RequestDeletion(ctx context.Context, workspaceID, id uuid.UUID, operation repository.Operation) (repository.Repository, repository.Operation, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.RequestRepositoryDeletion(ctx, sqlc.RequestRepositoryDeletionParams{ID: id, WorkspaceID: workspaceID})
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return repository.Repository{}, repository.Operation{}, stateConflict()
+ }
+
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ op, err := q.CreateRepositoryOperation(ctx, operationParams(operation))
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ dispatch, err := repositoryDispatch(operation.ID, operation.RepositoryID)
+ if err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ if err = recordRepositoryOperation(ctx, q, workspaceID, operation, audit.ActionRepositoryDeletionRequested); err != nil {
+ return repository.Repository{}, repository.Operation{}, err
+ }
+ err = tx.Commit(ctx)
+
+ return mapRepository(row), mapOperation(op), err
+}
+
+func (s *Store) Delete(ctx context.Context, workspaceID, id uuid.UUID) error {
+ count, err := s.queries.DeleteRepository(ctx, sqlc.DeleteRepositoryParams{ID: id, WorkspaceID: workspaceID})
+ if err == nil && count == 0 {
+ return pgx.ErrNoRows
+ }
+ return err
+}
+
+func (s *Store) CreateOperation(ctx context.Context, workspaceID uuid.UUID, operation repository.Operation) (repository.Operation, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return repository.Operation{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ row, err := q.CreateRepositoryOperation(ctx, operationParams(operation))
+ if err != nil {
+ return repository.Operation{}, err
+ }
+ if operation.Kind != "purge" {
+ count, err := q.SetRepositorySyncing(ctx, sqlc.SetRepositorySyncingParams{ID: operation.RepositoryID, ConfigVersion: operation.RepositoryVersion})
+ if err != nil {
+ return repository.Operation{}, err
+ }
+ if count != 1 {
+ return repository.Operation{}, stateConflict()
+ }
+ }
+ dispatch, err := repositoryDispatch(operation.ID, operation.RepositoryID)
+ if err != nil {
+ return repository.Operation{}, err
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, dispatchParams(dispatch)); err != nil {
+ return repository.Operation{}, err
+ }
+ if err = recordRepositoryOperation(ctx, q, workspaceID, operation, audit.ActionRepositoryRefreshRequested); err != nil {
+ return repository.Operation{}, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return repository.Operation{}, err
+ }
+
+ return mapOperation(row), nil
+}
+
+func (s *Store) ListOperations(ctx context.Context, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[repository.Operation], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListRepositoryOperations(ctx, sqlc.ListRepositoryOperationsParams{
+ RepositoryID: repositoryID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[repository.Operation]{}, err
+ }
+ items := make([]repository.Operation, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, mapOperation(row))
+ }
+
+ return pagination.Build(items, page.Limit, func(item repository.Operation) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) Snapshot(ctx context.Context, workspaceID, repositoryID, snapshotID uuid.UUID) (repository.Snapshot, error) {
+ row, err := s.queries.GetWorkspaceSnapshot(ctx, sqlc.GetWorkspaceSnapshotParams{ID: snapshotID, RepositoryID: repositoryID, WorkspaceID: workspaceID})
+
+ return mapSnapshot(row), err
+}
+
+func (s *Store) Snapshots(ctx context.Context, workspaceID, repositoryID uuid.UUID, page pagination.Request) (pagination.Page[repository.Snapshot], error) {
+ cursorTime, cursorID := pagecursor.SQL(page)
+ rows, err := s.queries.ListRepositorySnapshots(ctx, sqlc.ListRepositorySnapshotsParams{
+ RepositoryID: repositoryID,
+ WorkspaceID: workspaceID,
+ CursorTime: cursorTime,
+ CursorID: cursorID,
+ Limit: page.QueryLimit(),
+ })
+ if err != nil {
+ return pagination.Page[repository.Snapshot]{}, err
+ }
+ items := make([]repository.Snapshot, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, mapSnapshot(row))
+ }
+
+ return pagination.Build(items, page.Limit, func(item repository.Snapshot) (time.Time, uuid.UUID) {
+ return item.CreatedAt, item.ID
+ }), nil
+}
+
+func (s *Store) GetWorkItem(ctx context.Context, operationID uuid.UUID) (repository.WorkItem, error) {
+ op, err := s.queries.GetRepositoryOperation(ctx, operationID)
+ if err != nil {
+ return repository.WorkItem{}, err
+ }
+ var row sqlc.Repository
+ err = s.pool.QueryRow(ctx, `SELECT id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,config_version,git_path,status,current_snapshot_id,mirror_size_bytes,last_sync_at,last_error_code,last_error_message,archived_at,deleted_at,created_by,created_at,updated_at FROM repositories WHERE id=$1`, op.RepositoryID).Scan(
+ &row.ID, &row.WorkspaceID, &row.ProviderType, &row.Name, &row.RemoteUrl, &row.NormalizedUrl, &row.ConfiguredRef, &row.ConfigVersion, &row.GitPath, &row.Status, &row.CurrentSnapshotID, &row.MirrorSizeBytes, &row.LastSyncAt, &row.LastErrorCode, &row.LastErrorMessage, &row.ArchivedAt, &row.DeletedAt, &row.CreatedBy, &row.CreatedAt, &row.UpdatedAt,
+ )
+ if err != nil {
+ return repository.WorkItem{}, err
+ }
+ item := mapRepository(row)
+ if row.CurrentSnapshotID.Valid {
+ snapshot, snapshotErr := s.queries.GetSnapshot(ctx, sqlc.GetSnapshotParams{ID: row.CurrentSnapshotID.UUID, RepositoryID: row.ID})
+ if snapshotErr != nil {
+ return repository.WorkItem{}, snapshotErr
+ }
+ mapped := mapSnapshot(snapshot)
+ item.CurrentSnapshot = &mapped
+ }
+
+ return repository.WorkItem{Repository: item, Operation: mapOperation(op)}, nil
+}
+
+func (s *Store) StartOperation(ctx context.Context, id uuid.UUID) (repository.Operation, error) {
+ row, err := s.queries.StartRepositoryOperation(ctx, id)
+ return mapOperation(row), err
+}
+
+func (s *Store) CompleteSync(ctx context.Context, work repository.WorkItem, snapshotID uuid.UUID, result gitrepo.Snapshot) (repository.Operation, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return repository.Operation{}, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ snapshot, err := q.EnsureSnapshot(ctx, sqlc.EnsureSnapshotParams{ID: snapshotID, RepositoryID: work.Repository.ID, CommitSha: result.CommitSHA, SourceRef: work.Operation.RequestedRef, GitRef: "refs/mooncode/snapshots/" + snapshotID.String(), AuthorName: text(result.Author), AuthoredAt: pgtype.Timestamptz{Time: result.AuthoredAt, Valid: !result.AuthoredAt.IsZero()}, Title: text(result.Title)})
+ if err != nil {
+ return repository.Operation{}, err
+ }
+ if _, err = q.SetRepositoryCurrentSnapshot(ctx, sqlc.SetRepositoryCurrentSnapshotParams{
+ ID: work.Repository.ID, WorkspaceID: work.Repository.WorkspaceID,
+ CurrentSnapshotID: uuid.NullUUID{UUID: snapshot.ID, Valid: true},
+ ProviderType: work.Operation.RequestedProviderType, RemoteUrl: work.Operation.RequestedRemoteURL,
+ NormalizedUrl: work.Operation.RequestedNormalizedURL, ConfiguredRef: work.Operation.RequestedRef,
+ ConfigVersion: work.Operation.RepositoryVersion, MirrorSizeBytes: result.MirrorSizeBytes,
+ }); err != nil {
+ return repository.Operation{}, err
+ }
+ if work.Operation.PreviousNormalizedURL != work.Operation.RequestedNormalizedURL {
+ if _, err = q.ReleaseRepositorySource(ctx, sqlc.ReleaseRepositorySourceParams{
+ WorkspaceID: work.Repository.WorkspaceID, NormalizedUrl: work.Operation.PreviousNormalizedURL,
+ RepositoryID: work.Repository.ID,
+ }); err != nil {
+ return repository.Operation{}, err
+ }
+ }
+ outcome := "changed"
+ if work.Repository.CurrentSnapshot != nil && work.Repository.CurrentSnapshot.CommitSHA == result.CommitSHA {
+ outcome = "no_change"
+ }
+ op, err := q.FinishRepositoryOperation(ctx, sqlc.FinishRepositoryOperationParams{ID: work.Operation.ID, Outcome: text(outcome), ResolvedCommitSha: text(result.CommitSHA), SnapshotID: uuid.NullUUID{UUID: snapshot.ID, Valid: true}})
+ if err == nil {
+ err = tx.Commit(ctx)
+ }
+
+ return mapOperation(op), err
+}
+
+func (s *Store) CompletePurge(ctx context.Context, work repository.WorkItem) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ if err = q.MarkSnapshotsPurged(ctx, work.Repository.ID); err != nil {
+ return err
+ }
+ count, err := q.DeleteRepository(ctx, sqlc.DeleteRepositoryParams{ID: work.Repository.ID, WorkspaceID: work.Repository.WorkspaceID})
+ if err != nil || count != 1 {
+ return fault.New(fault.Conflict, "repository.deletion_conflict", "Repository could not be deleted")
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (s *Store) HasRetainedAnalysis(ctx context.Context, repositoryID uuid.UUID) (bool, error) {
+ return s.queries.HasRepositoryAnalysisRuns(ctx, repositoryID)
+}
+
+func (s *Store) CompleteRetainedDeletion(ctx context.Context, work repository.WorkItem) error {
+ count, err := s.queries.CompleteRepositoryLogicalDeletion(ctx, sqlc.CompleteRepositoryLogicalDeletionParams{ID: work.Operation.ID, RepositoryID: work.Repository.ID})
+ if err != nil {
+ return err
+ }
+ if count != 1 {
+ return fault.New(fault.Conflict, "repository.deletion_conflict", "Repository deletion could not be completed")
+ }
+
+ return nil
+}
+
+func (s *Store) FailOperation(ctx context.Context, work repository.WorkItem, failure repository.Failure) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ if _, err := q.FailRepositoryOperation(ctx, sqlc.FailRepositoryOperationParams{ID: work.Operation.ID, ErrorMessage: text(failure.Message)}); errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ } else if err != nil {
+ return err
+ }
+ if work.Operation.Kind == "purge" && work.Repository.DeletedAt != nil {
+ if _, err := q.RestoreDeletedRepositoryAfterPurgeFailure(ctx, sqlc.RestoreDeletedRepositoryAfterPurgeFailureParams{
+ ID: work.Repository.ID, ConfigVersion: work.Operation.RepositoryVersion,
+ LastErrorCode: text(failure.Code), LastErrorMessage: text(failure.Message),
+ }); err != nil {
+ return err
+ }
+ } else {
+ if _, err := q.ProjectRepositoryOperationFailure(ctx, sqlc.ProjectRepositoryOperationFailureParams{
+ ID: work.Repository.ID, ConfigVersion: work.Operation.RepositoryVersion,
+ PreviousProviderType: work.Operation.PreviousProviderType, PreviousRemoteUrl: work.Operation.PreviousRemoteURL,
+ PreviousNormalizedUrl: work.Operation.PreviousNormalizedURL, PreviousRef: work.Operation.PreviousRef,
+ LastErrorCode: text(failure.Code), LastErrorMessage: text(failure.Message),
+ }); err != nil {
+ return err
+ }
+ }
+ if work.Operation.RequestedNormalizedURL != work.Operation.PreviousNormalizedURL {
+ if _, err := q.ReleaseRepositorySource(ctx, sqlc.ReleaseRepositorySourceParams{
+ WorkspaceID: work.Repository.WorkspaceID, NormalizedUrl: work.Operation.RequestedNormalizedURL,
+ RepositoryID: work.Repository.ID,
+ }); err != nil {
+ return err
+ }
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (s *Store) CancelOperation(ctx context.Context, workspaceID, actorID, repositoryID, operationID uuid.UUID) (repository.Operation, bool, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return repository.Operation{}, false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+ operation, err := q.GetRepositoryOperation(ctx, operationID)
+ if errors.Is(err, pgx.ErrNoRows) || (err == nil && operation.RepositoryID != repositoryID) {
+ return repository.Operation{}, false, nil
+ }
+ if err != nil {
+ return repository.Operation{}, false, err
+ }
+ cancelled, err := q.CancelRepositoryOperation(ctx, sqlc.CancelRepositoryOperationParams{ID: operationID, RepositoryID: repositoryID})
+ if errors.Is(err, pgx.ErrNoRows) {
+ return repository.Operation{}, false, nil
+ }
+ if err != nil {
+ return repository.Operation{}, false, err
+ }
+ if err = q.CancelWorkflowDispatch(ctx, sqlc.CancelWorkflowDispatchParams{AggregateType: workflow.AggregateRepositoryOperation, AggregateID: operationID}); err != nil {
+ return repository.Operation{}, false, err
+ }
+ if _, err = q.RestoreRepositoryAfterOperation(ctx, sqlc.RestoreRepositoryAfterOperationParams{
+ ID: repositoryID, ConfigVersion: operation.RepositoryVersion,
+ PreviousProviderType: operation.PreviousProviderType, PreviousRemoteUrl: operation.PreviousRemoteUrl,
+ PreviousNormalizedUrl: operation.PreviousNormalizedUrl, PreviousRef: operation.PreviousRef,
+ }); err != nil {
+ return repository.Operation{}, false, err
+ }
+ if operation.RequestedNormalizedUrl != operation.PreviousNormalizedUrl {
+ if _, err = q.ReleaseRepositorySource(ctx, sqlc.ReleaseRepositorySourceParams{
+ WorkspaceID: workspaceID, NormalizedUrl: operation.RequestedNormalizedUrl,
+ RepositoryID: repositoryID,
+ }); err != nil {
+ return repository.Operation{}, false, err
+ }
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ WorkspaceID: workspaceID,
+ ActorUserID: actorID,
+ Action: audit.ActionRepositoryOperationCancelled,
+ Resource: audit.ResourceRepository,
+ ResourceID: repositoryID,
+ Metadata: map[string]any{"operationId": operationID},
+ }); err != nil {
+ return repository.Operation{}, false, err
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return repository.Operation{}, false, err
+ }
+
+ return mapOperation(cancelled), true, nil
+}
+
+func recordRepositoryOperation(ctx context.Context, writer audit.Writer, workspaceID uuid.UUID, operation repository.Operation, action string) error {
+ return audit.Record(ctx, writer, audit.Event{
+ WorkspaceID: workspaceID,
+ ActorUserID: operation.ActorUserID,
+ Action: action,
+ Resource: audit.ResourceRepository,
+ ResourceID: operation.RepositoryID,
+ Metadata: map[string]any{"operationId": operation.ID},
+ })
+}
+
+func repositoryDispatch(operationID, repositoryID uuid.UUID) (workflow.Dispatch, error) {
+ return workflow.NewDispatch(
+ workflow.AggregateRepositoryOperation,
+ operationID,
+ workflow.WorkflowRepositoryOperation,
+ workflow.Payload{AggregateID: operationID, RepositoryID: repositoryID},
+ )
+}
+
+func dispatchParams(dispatch workflow.Dispatch) sqlc.CreateWorkflowDispatchParams {
+ return sqlc.CreateWorkflowDispatchParams{
+ ID: dispatch.ID,
+ AggregateType: dispatch.AggregateType,
+ AggregateID: dispatch.AggregateID,
+ WorkflowName: dispatch.WorkflowName,
+ Payload: dispatch.Payload,
+ }
+}
+
+func operationParams(operation repository.Operation) sqlc.CreateRepositoryOperationParams {
+ params := sqlc.CreateRepositoryOperationParams{
+ ID: operation.ID, RepositoryID: operation.RepositoryID, ActorUserID: operation.ActorUserID,
+ RepositoryVersion: operation.RepositoryVersion, Kind: operation.Kind,
+ RequestedProviderType: operation.RequestedProviderType, RequestedRemoteUrl: operation.RequestedRemoteURL,
+ RequestedNormalizedUrl: operation.RequestedNormalizedURL, RequestedRef: operation.RequestedRef,
+ PreviousProviderType: operation.PreviousProviderType, PreviousRemoteUrl: operation.PreviousRemoteURL,
+ PreviousNormalizedUrl: operation.PreviousNormalizedURL, PreviousRef: operation.PreviousRef,
+ }
+ if operation.ProviderConnectionID != nil {
+ params.ProviderConnectionID = uuid.NullUUID{UUID: *operation.ProviderConnectionID, Valid: true}
+ params.CredentialVersion = pgtype.Int8{Int64: operation.CredentialVersion, Valid: true}
+ }
+ return params
+}
+
+func mapRepository(row sqlc.Repository) repository.Repository {
+ item := repository.Repository{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, ProviderType: row.ProviderType, Name: row.Name,
+ RemoteURL: row.RemoteUrl, NormalizedURL: row.NormalizedUrl, ConfiguredRef: row.ConfiguredRef,
+ ConfigVersion: row.ConfigVersion, Status: row.Status, MirrorSizeBytes: row.MirrorSizeBytes,
+ LastSyncAt: optionalTime(row.LastSyncAt), LastErrorCode: row.LastErrorCode.String, LastErrorMessage: row.LastErrorMessage.String,
+ ArchivedAt: optionalTime(row.ArchivedAt), DeletedAt: optionalTime(row.DeletedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
+ }
+ if row.CurrentSnapshotID.Valid {
+ id := row.CurrentSnapshotID.UUID
+ item.CurrentSnapshotID = &id
+ }
+ return item
+}
+
+func mapSnapshot(row sqlc.CommitSnapshot) repository.Snapshot {
+ return repository.Snapshot{ID: row.ID, RepositoryID: row.RepositoryID, CommitSHA: row.CommitSha, SourceRef: row.SourceRef, AuthorName: row.AuthorName.String, AuthoredAt: optionalTime(row.AuthoredAt), Title: row.Title.String, SourceState: row.SourceState, CreatedAt: row.CreatedAt.Time}
+}
+
+func mapOperation(row sqlc.RepositoryOperation) repository.Operation {
+ item := repository.Operation{
+ ID: row.ID, RepositoryID: row.RepositoryID, ActorUserID: row.ActorUserID,
+ CredentialVersion: row.CredentialVersion.Int64, RepositoryVersion: row.RepositoryVersion, Kind: row.Kind,
+ RequestedProviderType: row.RequestedProviderType, RequestedRemoteURL: row.RequestedRemoteUrl,
+ RequestedNormalizedURL: row.RequestedNormalizedUrl, RequestedRef: row.RequestedRef,
+ PreviousProviderType: row.PreviousProviderType, PreviousRemoteURL: row.PreviousRemoteUrl,
+ PreviousNormalizedURL: row.PreviousNormalizedUrl, PreviousRef: row.PreviousRef,
+ Status: row.Status, Outcome: row.Outcome.String, ResolvedCommitSHA: row.ResolvedCommitSha.String,
+ WorkflowRunID: row.WorkflowRunID.String, ErrorMessage: row.ErrorMessage.String,
+ CreatedAt: row.CreatedAt.Time, StartedAt: optionalTime(row.StartedAt), FinishedAt: optionalTime(row.FinishedAt),
+ }
+ if row.ProviderConnectionID.Valid {
+ id := row.ProviderConnectionID.UUID
+ item.ProviderConnectionID = &id
+ }
+ if row.SnapshotID.Valid {
+ id := row.SnapshotID.UUID
+ item.SnapshotID = &id
+ }
+ return item
+}
+
+func text(value string) pgtype.Text { return pgtype.Text{String: value, Valid: value != ""} }
+
+func sourceConflict() error {
+ return fault.New(fault.Conflict, "repository.source_conflict", "Repository source is already managed by this workspace")
+}
+
+func stateConflict() error {
+ return fault.New(fault.Conflict, "repository.state_conflict", "Repository state changed while the operation was requested")
+}
+
+func optionalTime(value pgtype.Timestamptz) *time.Time {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Time
+ return &result
+}
diff --git a/internal/repository/data/store_integration_test.go b/internal/repository/data/store_integration_test.go
new file mode 100644
index 0000000..b1fd0da
--- /dev/null
+++ b/internal/repository/data/store_integration_test.go
@@ -0,0 +1,453 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ "github.com/fuchencong/mooncode/internal/platform/pagination"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func TestRepositoryOperationProjectsSyncLifecycle(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+ manager, err := gitrepo.NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewStore(pool, manager)
+
+ userID, workspaceID, repositoryID := uuid.New(), uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Repository projection',$2,$3)`, workspaceID, "repository-projection-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by)
+VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), manager.Path(repositoryID), userID); err != nil {
+ t.Fatal(err)
+ }
+
+ newOperation := func() repository.Operation {
+ return repository.Operation{
+ ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID,
+ RepositoryVersion: 1, Kind: "refresh",
+ RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git",
+ RequestedNormalizedURL: "github.com/example/" + repositoryID.String(), RequestedRef: "main",
+ PreviousProviderType: "github", PreviousRemoteURL: "https://github.com/example/repo.git",
+ PreviousNormalizedURL: "github.com/example/" + repositoryID.String(), PreviousRef: "main",
+ Status: "queued",
+ }
+ }
+
+ first := newOperation()
+ if _, err := store.CreateOperation(ctx, workspaceID, first); err != nil {
+ t.Fatal(err)
+ }
+ assertRepositoryProjection(t, pool, repositoryID, "syncing", 0, "")
+ work, err := store.GetWorkItem(ctx, first.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, first.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.CompleteSync(ctx, work, uuid.New(), gitrepo.Snapshot{CommitSHA: "1111111111111111111111111111111111111111", MirrorSizeBytes: 1234}); err != nil {
+ t.Fatal(err)
+ }
+ item, err := store.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if item.Status != "ready" || item.MirrorSizeBytes != 1234 || item.LastSyncAt == nil || item.CurrentSnapshot == nil || item.LastErrorCode != "" {
+ t.Fatalf("successful sync projection = %+v", item)
+ }
+
+ failed := newOperation()
+ if _, err := store.CreateOperation(ctx, workspaceID, failed); err != nil {
+ t.Fatal(err)
+ }
+ failedWork, err := store.GetWorkItem(ctx, failed.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, failed.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.FailOperation(ctx, failedWork, repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}); err != nil {
+ t.Fatal(err)
+ }
+ assertRepositoryProjection(t, pool, repositoryID, "ready", 1234, "repository.sync_failed")
+
+ cancelled := newOperation()
+ if _, err := store.CreateOperation(ctx, workspaceID, cancelled); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE repository_operations SET workflow_run_id='repository-workflow-run' WHERE id=$1`, cancelled.ID); err != nil {
+ t.Fatal(err)
+ }
+ cancelledOperation, ok, err := store.CancelOperation(ctx, workspaceID, userID, repositoryID, cancelled.ID)
+ if err != nil || !ok {
+ t.Fatalf("cancel operation = (%v, %v)", ok, err)
+ }
+ if cancelledOperation.WorkflowRunID != "repository-workflow-run" {
+ t.Fatalf("cancel operation workflow run ID = %q", cancelledOperation.WorkflowRunID)
+ }
+ assertRepositoryProjection(t, pool, repositoryID, "ready", 1234, "repository.sync_failed")
+
+ stale := newOperation()
+ if _, err := store.CreateOperation(ctx, workspaceID, stale); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE repositories SET config_version=2,status='ready',last_error_code=NULL,last_error_message=NULL WHERE id=$1`, repositoryID); err != nil {
+ t.Fatal(err)
+ }
+ staleWork, err := store.GetWorkItem(ctx, stale.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, stale.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.FailOperation(ctx, staleWork, repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}); err != nil {
+ t.Fatal(err)
+ }
+ assertRepositoryProjection(t, pool, repositoryID, "ready", 1234, "")
+ assertRepositoryAudit(t, pool, workspaceID, userID, repositoryID)
+ assertOperationPagination(t, store, repositoryID)
+}
+
+func TestRepositorySyncReusesSnapshotForUnchangedCommit(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+ manager, err := gitrepo.NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewStore(pool, manager)
+
+ userID, workspaceID, repositoryID, snapshotID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ commitSHA := "1111111111111111111111111111111111111111"
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Snapshot reuse',$2,$3)`, workspaceID, "snapshot-reuse-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by)
+VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'ready',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), manager.Path(repositoryID), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state)
+VALUES ($1,$2,$3,'main',$4,'available')`, snapshotID, repositoryID, commitSHA, "refs/mooncode/snapshots/"+snapshotID.String()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, repositoryID, snapshotID); err != nil {
+ t.Fatal(err)
+ }
+
+ operation := repository.Operation{
+ ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID,
+ RepositoryVersion: 1, Kind: "refresh",
+ RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git",
+ RequestedNormalizedURL: "github.com/example/" + repositoryID.String(), RequestedRef: "main",
+ PreviousProviderType: "github", PreviousRemoteURL: "https://github.com/example/repo.git",
+ PreviousNormalizedURL: "github.com/example/" + repositoryID.String(), PreviousRef: "main",
+ Status: "queued",
+ }
+ if _, err := store.CreateOperation(ctx, workspaceID, operation); err != nil {
+ t.Fatal(err)
+ }
+ work, err := store.GetWorkItem(ctx, operation.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ completed, err := store.CompleteSync(ctx, work, operation.ID, gitrepo.Snapshot{CommitSHA: commitSHA, MirrorSizeBytes: 42})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if completed.Outcome != "no_change" || completed.SnapshotID == nil || *completed.SnapshotID != snapshotID {
+ t.Fatalf("unchanged operation = %+v, want existing snapshot %s", completed, snapshotID)
+ }
+
+ var snapshotCount int
+ var currentSnapshotID uuid.UUID
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM commit_snapshots WHERE repository_id=$1`, repositoryID).Scan(&snapshotCount); err != nil {
+ t.Fatal(err)
+ }
+ if err := pool.QueryRow(ctx, `SELECT current_snapshot_id FROM repositories WHERE id=$1`, repositoryID).Scan(¤tSnapshotID); err != nil {
+ t.Fatal(err)
+ }
+ if snapshotCount != 1 || currentSnapshotID != snapshotID {
+ t.Fatalf("snapshot count/current = %d/%s, want 1/%s", snapshotCount, currentSnapshotID, snapshotID)
+ }
+}
+
+func TestRepositorySourceUpdateRollsBackOnFailureAndCommitsOnSuccess(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+ manager, err := gitrepo.NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := NewStore(pool, manager)
+
+ userID, workspaceID, repositoryID := uuid.New(), uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Source update',$2,$3)`, workspaceID, "source-update-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by)
+VALUES ($1,$2,'github','repo','https://github.com/example/repo.git','github.com/example/repo','main',$3,'ready',$4)`, repositoryID, workspaceID, manager.Path(repositoryID), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO repository_source_keys (workspace_id,normalized_url,repository_id) VALUES ($1,'github.com/example/repo',$2)`, workspaceID, repositoryID); err != nil {
+ t.Fatal(err)
+ }
+
+ operation := repository.Operation{
+ ID: uuid.New(), RepositoryID: repositoryID, ActorUserID: userID,
+ RepositoryVersion: 2, Kind: "update",
+ RequestedProviderType: "gitlab", RequestedRemoteURL: "https://gitlab.com/example/repo.git",
+ RequestedNormalizedURL: "gitlab.com/example/repo", RequestedRef: "release",
+ PreviousProviderType: "github", PreviousRemoteURL: "https://github.com/example/repo.git",
+ PreviousNormalizedURL: "github.com/example/repo", PreviousRef: "main",
+ Status: "queued",
+ }
+ staged, _, err := store.UpdateWithOperation(ctx, workspaceID, repositoryID, "repo", operation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if staged.Status != "syncing" || staged.ProviderType != "gitlab" || staged.RemoteURL != operation.RequestedRemoteURL || staged.ConfiguredRef != "release" {
+ t.Fatalf("staged repository source = %+v", staged)
+ }
+ competingID := uuid.New()
+ competing := repository.Repository{
+ ID: competingID, WorkspaceID: workspaceID, ProviderType: "github", Name: "competing",
+ RemoteURL: "https://github.com/example/repo.git", NormalizedURL: "github.com/example/repo",
+ ConfiguredRef: "main", Status: "provisioning",
+ }
+ competingOperation := repository.Operation{
+ ID: uuid.New(), RepositoryID: competingID, ActorUserID: userID,
+ RepositoryVersion: 1, Kind: "provision",
+ RequestedProviderType: "github", RequestedRemoteURL: competing.RemoteURL,
+ RequestedNormalizedURL: competing.NormalizedURL, RequestedRef: "main",
+ PreviousProviderType: "github", PreviousRemoteURL: competing.RemoteURL,
+ PreviousNormalizedURL: competing.NormalizedURL, PreviousRef: "main",
+ Status: "queued",
+ }
+ if _, _, err := store.Create(ctx, competing, competingOperation); err == nil {
+ t.Fatal("source update released the previous repository URL before completion")
+ }
+ var competingCount int
+ if err := pool.QueryRow(ctx, `SELECT count(*) FROM repositories WHERE id=$1`, competingID).Scan(&competingCount); err != nil {
+ t.Fatal(err)
+ }
+ if competingCount != 0 {
+ t.Fatal("failed source reservation did not roll back the competing repository")
+ }
+ work, err := store.GetWorkItem(ctx, operation.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.FailOperation(ctx, work, repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}); err != nil {
+ t.Fatal(err)
+ }
+ rolledBack, err := store.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rolledBack.Status != "failed" || rolledBack.ProviderType != "github" || rolledBack.RemoteURL != operation.PreviousRemoteURL || rolledBack.ConfiguredRef != "main" || rolledBack.ConfigVersion != 2 {
+ t.Fatalf("failed source update was not rolled back: %+v", rolledBack)
+ }
+ assertRepositorySourceKeys(t, pool, repositoryID, []string{"github.com/example/repo"})
+
+ operation.ID = uuid.New()
+ operation.RepositoryVersion = 3
+ if _, _, err := store.UpdateWithOperation(ctx, workspaceID, repositoryID, "repo", operation); err != nil {
+ t.Fatal(err)
+ }
+ work, err = store.GetWorkItem(ctx, operation.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.CompleteSync(ctx, work, operation.ID, gitrepo.Snapshot{CommitSHA: "2222222222222222222222222222222222222222", MirrorSizeBytes: 42}); err != nil {
+ t.Fatal(err)
+ }
+ committed, err := store.Get(ctx, workspaceID, repositoryID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if committed.Status != "ready" || committed.ProviderType != "gitlab" || committed.RemoteURL != operation.RequestedRemoteURL || committed.ConfiguredRef != "release" || committed.CurrentSnapshot == nil || committed.CurrentSnapshot.CommitSHA != "2222222222222222222222222222222222222222" {
+ t.Fatalf("successful source update was not committed: %+v", committed)
+ }
+ assertRepositorySourceKeys(t, pool, repositoryID, []string{"gitlab.com/example/repo"})
+
+ operation.ID = uuid.New()
+ operation.Kind = "purge"
+ operation.PreviousProviderType = operation.RequestedProviderType
+ operation.PreviousRemoteURL = operation.RequestedRemoteURL
+ operation.PreviousNormalizedURL = operation.RequestedNormalizedURL
+ operation.PreviousRef = operation.RequestedRef
+ if _, err := store.CreateOperation(ctx, workspaceID, operation); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE repositories SET status='deleting',deleted_at=now() WHERE id=$1`, repositoryID); err != nil {
+ t.Fatal(err)
+ }
+ work, err = store.GetWorkItem(ctx, operation.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.StartOperation(ctx, operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.FailOperation(ctx, work, repository.Failure{Code: "repository.purge_failed", Message: "Repository deletion failed"}); err != nil {
+ t.Fatal(err)
+ }
+ var status, errorCode string
+ if err := pool.QueryRow(ctx, `SELECT status,COALESCE(last_error_code,'') FROM repositories WHERE id=$1`, repositoryID).Scan(&status, &errorCode); err != nil {
+ t.Fatal(err)
+ }
+ if status != "deleted" || errorCode != "repository.purge_failed" {
+ t.Fatalf("failed retention purge projection = (%q, %q), want deleted repository", status, errorCode)
+ }
+}
+
+func assertRepositorySourceKeys(t *testing.T, pool *pgxpool.Pool, repositoryID uuid.UUID, want []string) {
+ t.Helper()
+
+ rows, err := pool.Query(context.Background(), `SELECT normalized_url FROM repository_source_keys WHERE repository_id=$1 ORDER BY normalized_url`, repositoryID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ var got []string
+ for rows.Next() {
+ var value string
+ if err := rows.Scan(&value); err != nil {
+ t.Fatal(err)
+ }
+ got = append(got, value)
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != len(want) {
+ t.Fatalf("repository source keys = %v, want %v", got, want)
+ }
+ for index := range want {
+ if got[index] != want[index] {
+ t.Fatalf("repository source keys = %v, want %v", got, want)
+ }
+ }
+}
+
+func assertOperationPagination(t *testing.T, store *Store, repositoryID uuid.UUID) {
+ t.Helper()
+
+ first, err := store.ListOperations(context.Background(), repositoryID, pagination.Request{Limit: 2})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(first.Items) != 2 || first.NextCursor == "" {
+ t.Fatalf("first operation page = %+v, want two items and a cursor", first)
+ }
+ request, err := pagination.Parse(first.NextCursor, "2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := store.ListOperations(context.Background(), repositoryID, request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(second.Items) != 2 || second.NextCursor != "" {
+ t.Fatalf("second operation page = %+v, want final two items", second)
+ }
+ seen := make(map[uuid.UUID]struct{}, 4)
+ for _, operation := range append(first.Items, second.Items...) {
+ seen[operation.ID] = struct{}{}
+ }
+ if len(seen) != 4 {
+ t.Fatalf("operation pages contained duplicates: %+v %+v", first.Items, second.Items)
+ }
+}
+
+func assertRepositoryAudit(t *testing.T, pool *pgxpool.Pool, workspaceID, actorID, repositoryID uuid.UUID) {
+ t.Helper()
+
+ rows, err := pool.Query(context.Background(), `SELECT workspace_id,actor_user_id,action FROM audit_events WHERE resource_type=$1 AND resource_id=$2`, audit.ResourceRepository, repositoryID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rows.Close()
+ seen := make(map[string]int)
+ for rows.Next() {
+ var eventWorkspaceID, eventActorID uuid.UUID
+ var action string
+ if err := rows.Scan(&eventWorkspaceID, &eventActorID, &action); err != nil {
+ t.Fatal(err)
+ }
+ if eventWorkspaceID != workspaceID || eventActorID != actorID {
+ t.Fatalf("repository audit identity = (%s, %s), want (%s, %s)", eventWorkspaceID, eventActorID, workspaceID, actorID)
+ }
+ seen[action]++
+ }
+ if err := rows.Err(); err != nil {
+ t.Fatal(err)
+ }
+ if seen[audit.ActionRepositoryRefreshRequested] != 4 || seen[audit.ActionRepositoryOperationCancelled] != 1 {
+ t.Fatalf("repository audit actions = %v, want four refresh requests and one cancellation", seen)
+ }
+}
+
+func assertRepositoryProjection(t *testing.T, pool *pgxpool.Pool, repositoryID uuid.UUID, wantStatus string, wantSize int64, wantCode string) {
+ t.Helper()
+ var status, code string
+ var size int64
+ if err := pool.QueryRow(context.Background(), `SELECT status,mirror_size_bytes,COALESCE(last_error_code,'') FROM repositories WHERE id=$1`, repositoryID).Scan(&status, &size, &code); err != nil {
+ t.Fatal(err)
+ }
+ if status != wantStatus || size != wantSize || code != wantCode {
+ t.Fatalf("repository projection = (%q, %d, %q), want (%q, %d, %q)", status, size, code, wantStatus, wantSize, wantCode)
+ }
+}
diff --git a/internal/repository/identity.go b/internal/repository/identity.go
deleted file mode 100644
index 412134b..0000000
--- a/internal/repository/identity.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
-)
-
-var (
- ErrNotFound = errors.New("repository: not found")
- ErrConflict = errors.New("repository: conflict")
-)
-
-type IdentityStore interface {
- WithinIdentityTx(ctx context.Context, fn func(IdentityStore) error) error
- UpsertUser(ctx context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error)
- GetUserByExternalIdentity(ctx context.Context, issuer, subject string) (model.User, error)
- GetUserForUpdate(ctx context.Context, userID uuid.UUID) (model.User, error)
- ActivateUser(ctx context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error)
- GetPersonalWorkspace(ctx context.Context, userID uuid.UUID) (model.Workspace, error)
- CreateWorkspace(ctx context.Context, workspace model.Workspace) (model.Workspace, error)
- AddWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error
- ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]model.Workspace, error)
- GetWorkspaceMembership(ctx context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error)
- UpdateWorkspace(ctx context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error)
- ListWorkspaceMembers(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error)
- UpsertWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error
- DeleteWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID) error
- CreateWorkspaceInvitation(ctx context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error)
- ListWorkspaceInvitations(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error)
- GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (model.WorkspaceInvitation, error)
- AcceptWorkspaceInvitation(ctx context.Context, invitationID, userID uuid.UUID) error
- RevokeWorkspaceInvitation(ctx context.Context, invitationID uuid.UUID) error
- AppendAccountAudit(ctx context.Context, entry model.AccountAuditLog) error
- AppendIdentityAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error
- AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error
-}
diff --git a/internal/repository/memory/identity.go b/internal/repository/memory/identity.go
deleted file mode 100644
index 0916f81..0000000
--- a/internal/repository/memory/identity.go
+++ /dev/null
@@ -1,483 +0,0 @@
-package memory
-
-import (
- "context"
- "sync"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-type Option func(*Store)
-
-func WithClock(clock func() time.Time) Option {
- return func(store *Store) {
- if clock != nil {
- store.clock = clock
- }
- }
-}
-
-type Store struct {
- mu sync.RWMutex
- clock func() time.Time
- users map[string]model.User
- workspaces map[uuid.UUID]model.Workspace
- members map[uuid.UUID]map[uuid.UUID]string
- invitations map[uuid.UUID]model.WorkspaceInvitation
- invitationTokens map[string]uuid.UUID
- accountAudits []model.AccountAuditLog
-}
-
-func New(opts ...Option) *Store {
- store := &Store{
- clock: time.Now, users: make(map[string]model.User),
- workspaces: make(map[uuid.UUID]model.Workspace), members: make(map[uuid.UUID]map[uuid.UUID]string),
- invitations: make(map[uuid.UUID]model.WorkspaceInvitation), invitationTokens: make(map[string]uuid.UUID),
- }
- for _, option := range opts {
- if option != nil {
- option(store)
- }
- }
- return store
-}
-
-func (s *Store) WithinIdentityTx(_ context.Context, fn func(repository.IdentityStore) error) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- users, workspaces, members := cloneUsers(s.users), cloneWorkspaces(s.workspaces), cloneMembers(s.members)
- invitations, invitationTokens := cloneInvitations(s.invitations), cloneInvitationTokens(s.invitationTokens)
- audits := append([]model.AccountAuditLog(nil), s.accountAudits...)
- tx := &identityTx{store: s}
- if err := fn(tx); err != nil {
- s.users, s.workspaces, s.members = users, workspaces, members
- s.invitations, s.invitationTokens, s.accountAudits = invitations, invitationTokens, audits
- return err
- }
- return nil
-}
-
-func (s *Store) UpsertUser(_ context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.upsertUser(id, identity), nil
-}
-
-func (s *Store) GetUserByExternalIdentity(_ context.Context, issuer, subject string) (model.User, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.getUserByExternalIdentity(issuer, subject)
-}
-
-func (s *Store) GetUserForUpdate(_ context.Context, userID uuid.UUID) (model.User, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.getUserByID(userID)
-}
-
-func (s *Store) ActivateUser(_ context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.activateUser(userID, termsVersion, privacyVersion)
-}
-
-func (s *Store) GetPersonalWorkspace(_ context.Context, userID uuid.UUID) (model.Workspace, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.personalWorkspace(userID)
-}
-
-func (s *Store) CreateWorkspace(_ context.Context, workspace model.Workspace) (model.Workspace, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.createWorkspace(workspace), nil
-}
-
-func (s *Store) AddWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.addMember(workspaceID, userID, role)
-}
-
-func (s *Store) ListWorkspaces(_ context.Context, userID uuid.UUID) ([]model.Workspace, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.listWorkspaces(userID), nil
-}
-
-func (s *Store) GetWorkspaceMembership(_ context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.workspaceMembership(workspaceID, userID)
-}
-func (s *Store) UpdateWorkspace(_ context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.updateWorkspace(workspaceID, name)
-}
-func (s *Store) ListWorkspaceMembers(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.listMembers(workspaceID), nil
-}
-func (s *Store) UpsertWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.addMember(workspaceID, userID, role)
-}
-func (s *Store) DeleteWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.deleteMember(workspaceID, userID)
-}
-func (s *Store) CreateWorkspaceInvitation(_ context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.createInvitation(invitation, tokenHash)
-}
-func (s *Store) ListWorkspaceInvitations(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.listInvitations(workspaceID), nil
-}
-func (s *Store) GetWorkspaceInvitationForUpdate(_ context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
- return s.getInvitation(tokenHash)
-}
-func (s *Store) AcceptWorkspaceInvitation(_ context.Context, invitationID, userID uuid.UUID) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.acceptInvitation(invitationID, userID)
-}
-func (s *Store) RevokeWorkspaceInvitation(_ context.Context, invitationID uuid.UUID) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.revokeInvitation(invitationID)
-}
-func (s *Store) AppendAccountAudit(_ context.Context, entry model.AccountAuditLog) error {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.appendAccountAudit(entry)
- return nil
-}
-func (*Store) AppendIdentityAudit(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, []byte) error {
- return nil
-}
-func (*Store) AppendAuditResult(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, string, []byte) error {
- return nil
-}
-
-type identityTx struct{ store *Store }
-
-func (t *identityTx) WithinIdentityTx(_ context.Context, fn func(repository.IdentityStore) error) error {
- return fn(t)
-}
-func (t *identityTx) UpsertUser(_ context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) {
- return t.store.upsertUser(id, identity), nil
-}
-func (t *identityTx) GetUserByExternalIdentity(_ context.Context, issuer, subject string) (model.User, error) {
- return t.store.getUserByExternalIdentity(issuer, subject)
-}
-func (t *identityTx) GetUserForUpdate(_ context.Context, userID uuid.UUID) (model.User, error) {
- return t.store.getUserByID(userID)
-}
-func (t *identityTx) ActivateUser(_ context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) {
- return t.store.activateUser(userID, termsVersion, privacyVersion)
-}
-func (t *identityTx) GetPersonalWorkspace(_ context.Context, userID uuid.UUID) (model.Workspace, error) {
- return t.store.personalWorkspace(userID)
-}
-func (t *identityTx) CreateWorkspace(_ context.Context, workspace model.Workspace) (model.Workspace, error) {
- return t.store.createWorkspace(workspace), nil
-}
-func (t *identityTx) AddWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error {
- return t.store.addMember(workspaceID, userID, role)
-}
-func (t *identityTx) ListWorkspaces(_ context.Context, userID uuid.UUID) ([]model.Workspace, error) {
- return t.store.listWorkspaces(userID), nil
-}
-func (t *identityTx) GetWorkspaceMembership(_ context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) {
- return t.store.workspaceMembership(workspaceID, userID)
-}
-func (t *identityTx) UpdateWorkspace(_ context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) {
- return t.store.updateWorkspace(workspaceID, name)
-}
-func (t *identityTx) ListWorkspaceMembers(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) {
- return t.store.listMembers(workspaceID), nil
-}
-func (t *identityTx) UpsertWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID, role string) error {
- return t.store.addMember(workspaceID, userID, role)
-}
-func (t *identityTx) DeleteWorkspaceMember(_ context.Context, workspaceID, userID uuid.UUID) error {
- return t.store.deleteMember(workspaceID, userID)
-}
-func (t *identityTx) CreateWorkspaceInvitation(_ context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) {
- return t.store.createInvitation(invitation, tokenHash)
-}
-func (t *identityTx) ListWorkspaceInvitations(_ context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) {
- return t.store.listInvitations(workspaceID), nil
-}
-func (t *identityTx) GetWorkspaceInvitationForUpdate(_ context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) {
- return t.store.getInvitation(tokenHash)
-}
-func (t *identityTx) AcceptWorkspaceInvitation(_ context.Context, invitationID, userID uuid.UUID) error {
- return t.store.acceptInvitation(invitationID, userID)
-}
-func (t *identityTx) RevokeWorkspaceInvitation(_ context.Context, invitationID uuid.UUID) error {
- return t.store.revokeInvitation(invitationID)
-}
-func (t *identityTx) AppendAccountAudit(_ context.Context, entry model.AccountAuditLog) error {
- t.store.appendAccountAudit(entry)
- return nil
-}
-func (*identityTx) AppendIdentityAudit(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, []byte) error {
- return nil
-}
-func (*identityTx) AppendAuditResult(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, string, []byte) error {
- return nil
-}
-
-func (s *Store) upsertUser(id uuid.UUID, identity model.ExternalIdentity) model.User {
- key := identity.Issuer + "\x00" + identity.Subject
- now := s.clock().UTC()
- user, exists := s.users[key]
- if !exists {
- user = model.User{ID: id, Issuer: identity.Issuer, ExternalSubject: identity.Subject, Status: "pending", CreatedAt: now}
- }
- user.Username, user.Email, user.DisplayName, user.UpdatedAt, user.LastSeenAt = identity.Username, identity.Email, identity.DisplayName, now, timePointer(now)
- s.users[key] = user
- return user
-}
-
-func (s *Store) getUserByExternalIdentity(issuer, subject string) (model.User, error) {
- user, ok := s.users[issuer+"\x00"+subject]
- if !ok {
- return model.User{}, repository.ErrNotFound
- }
- return user, nil
-}
-
-func (s *Store) getUserByID(userID uuid.UUID) (model.User, error) {
- for _, user := range s.users {
- if user.ID == userID {
- return user, nil
- }
- }
- return model.User{}, repository.ErrNotFound
-}
-
-func (s *Store) activateUser(userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) {
- for key, user := range s.users {
- if user.ID != userID {
- continue
- }
- now := s.clock().UTC()
- if user.Status == "pending" {
- user.Status = "active"
- user.TermsVersion, user.PrivacyVersion = termsVersion, privacyVersion
- user.ActivatedAt, user.AgreementsAcceptedAt = timePointer(now), timePointer(now)
- user.UpdatedAt = now
- s.users[key] = user
- }
- return user, nil
- }
- return model.User{}, repository.ErrNotFound
-}
-
-func (s *Store) personalWorkspace(userID uuid.UUID) (model.Workspace, error) {
- for id, roles := range s.members {
- if role, ok := roles[userID]; ok && s.workspaces[id].Kind == "personal" {
- workspace := s.workspaces[id]
- workspace.Role = role
- return workspace, nil
- }
- }
- return model.Workspace{}, repository.ErrNotFound
-}
-
-func (s *Store) createWorkspace(workspace model.Workspace) model.Workspace {
- now := s.clock().UTC()
- workspace.CreatedAt, workspace.UpdatedAt = now, now
- s.workspaces[workspace.ID] = workspace
- return workspace
-}
-
-func (s *Store) addMember(workspaceID, userID uuid.UUID, role string) error {
- if _, ok := s.workspaces[workspaceID]; !ok {
- return repository.ErrNotFound
- }
- if s.members[workspaceID] == nil {
- s.members[workspaceID] = make(map[uuid.UUID]string)
- }
- if _, exists := s.members[workspaceID][userID]; exists {
- return repository.ErrConflict
- }
- s.members[workspaceID][userID] = role
- return nil
-}
-
-func (s *Store) listWorkspaces(userID uuid.UUID) []model.Workspace {
- result := make([]model.Workspace, 0)
- for id, roles := range s.members {
- if role, ok := roles[userID]; ok {
- workspace := s.workspaces[id]
- workspace.Role = role
- result = append(result, workspace)
- }
- }
- return result
-}
-
-func (s *Store) workspaceMembership(workspaceID, userID uuid.UUID) (model.Workspace, error) {
- role, ok := s.members[workspaceID][userID]
- if !ok {
- return model.Workspace{}, repository.ErrNotFound
- }
- workspace := s.workspaces[workspaceID]
- workspace.Role = role
- return workspace, nil
-}
-
-func (s *Store) updateWorkspace(workspaceID uuid.UUID, name string) (model.Workspace, error) {
- workspace, ok := s.workspaces[workspaceID]
- if !ok {
- return model.Workspace{}, repository.ErrNotFound
- }
- workspace.Name, workspace.UpdatedAt = name, s.clock().UTC()
- s.workspaces[workspaceID] = workspace
- return workspace, nil
-}
-
-func (s *Store) listMembers(workspaceID uuid.UUID) []model.WorkspaceMember {
- result := make([]model.WorkspaceMember, 0, len(s.members[workspaceID]))
- for userID, role := range s.members[workspaceID] {
- var user model.User
- for _, candidate := range s.users {
- if candidate.ID == userID {
- user = candidate
- break
- }
- }
- result = append(result, model.WorkspaceMember{WorkspaceID: workspaceID, UserID: userID, Role: role, Username: user.Username, Email: user.Email, DisplayName: user.DisplayName})
- }
- return result
-}
-
-func (s *Store) deleteMember(workspaceID, userID uuid.UUID) error {
- if _, ok := s.members[workspaceID][userID]; !ok {
- return repository.ErrNotFound
- }
- delete(s.members[workspaceID], userID)
- return nil
-}
-
-func (s *Store) createInvitation(invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) {
- key := string(tokenHash)
- if _, exists := s.invitationTokens[key]; exists {
- return model.WorkspaceInvitation{}, repository.ErrConflict
- }
- now := s.clock().UTC()
- invitation.CreatedAt, invitation.UpdatedAt = now, now
- s.invitations[invitation.ID] = invitation
- s.invitationTokens[key] = invitation.ID
- return invitation, nil
-}
-
-func (s *Store) listInvitations(workspaceID uuid.UUID) []model.WorkspaceInvitation {
- result := make([]model.WorkspaceInvitation, 0)
- for _, invitation := range s.invitations {
- if invitation.WorkspaceID == workspaceID {
- result = append(result, invitation)
- }
- }
- return result
-}
-
-func (s *Store) getInvitation(tokenHash []byte) (model.WorkspaceInvitation, error) {
- id, ok := s.invitationTokens[string(tokenHash)]
- if !ok {
- return model.WorkspaceInvitation{}, repository.ErrNotFound
- }
- return s.invitations[id], nil
-}
-
-func (s *Store) acceptInvitation(invitationID, userID uuid.UUID) error {
- invitation, ok := s.invitations[invitationID]
- if !ok || invitation.AcceptedAt != nil || invitation.RevokedAt != nil {
- return repository.ErrConflict
- }
- now := s.clock().UTC()
- invitation.AcceptedAt, invitation.AcceptedBy, invitation.UpdatedAt = timePointer(now), uuidPointer(userID), now
- s.invitations[invitationID] = invitation
- return nil
-}
-
-func (s *Store) revokeInvitation(invitationID uuid.UUID) error {
- invitation, ok := s.invitations[invitationID]
- if !ok {
- return repository.ErrNotFound
- }
- if invitation.AcceptedAt != nil || invitation.RevokedAt != nil {
- return repository.ErrConflict
- }
- now := s.clock().UTC()
- invitation.RevokedAt, invitation.UpdatedAt = timePointer(now), now
- s.invitations[invitationID] = invitation
- return nil
-}
-
-func (s *Store) appendAccountAudit(entry model.AccountAuditLog) {
- if entry.ID == uuid.Nil {
- entry.ID = uuid.New()
- }
- if entry.OccurredAt.IsZero() {
- entry.OccurredAt = s.clock().UTC()
- }
- s.accountAudits = append(s.accountAudits, entry)
-}
-
-func timePointer(value time.Time) *time.Time { return &value }
-func uuidPointer(value uuid.UUID) *uuid.UUID { return &value }
-
-func cloneUsers(source map[string]model.User) map[string]model.User {
- result := make(map[string]model.User, len(source))
- for key, value := range source {
- result[key] = value
- }
- return result
-}
-func cloneWorkspaces(source map[uuid.UUID]model.Workspace) map[uuid.UUID]model.Workspace {
- result := make(map[uuid.UUID]model.Workspace, len(source))
- for key, value := range source {
- result[key] = value
- }
- return result
-}
-func cloneMembers(source map[uuid.UUID]map[uuid.UUID]string) map[uuid.UUID]map[uuid.UUID]string {
- result := make(map[uuid.UUID]map[uuid.UUID]string, len(source))
- for workspaceID, members := range source {
- result[workspaceID] = make(map[uuid.UUID]string, len(members))
- for userID, role := range members {
- result[workspaceID][userID] = role
- }
- }
- return result
-}
-func cloneInvitations(source map[uuid.UUID]model.WorkspaceInvitation) map[uuid.UUID]model.WorkspaceInvitation {
- result := make(map[uuid.UUID]model.WorkspaceInvitation, len(source))
- for key, value := range source {
- result[key] = value
- }
- return result
-}
-func cloneInvitationTokens(source map[string]uuid.UUID) map[string]uuid.UUID {
- result := make(map[string]uuid.UUID, len(source))
- for key, value := range source {
- result[key] = value
- }
- return result
-}
diff --git a/internal/repository/mutation.go b/internal/repository/mutation.go
deleted file mode 100644
index 7487e81..0000000
--- a/internal/repository/mutation.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package repository
-
-import (
- "context"
- "time"
-
- "github.com/google/uuid"
-)
-
-type MutationRequest struct {
- WorkspaceID uuid.UUID
- Operation string
- ResourceKey string
- IdempotencyKey string
- RequestHash string
- ResourceID uuid.UUID
- ActiveUntil time.Time
- NextAllowedAt time.Time
-}
-
-type MutationClaim struct {
- Replay bool
- ResourceID uuid.UUID
-}
-
-// MutationStore provides a database-backed gate for expensive or high-risk
-// synchronous changes. Implementations must serialize BeginMutation for a
-// workspace/operation/resource key across process replicas.
-type MutationStore interface {
- BeginMutation(ctx context.Context, request MutationRequest) (MutationClaim, error)
- FinishMutation(ctx context.Context, request MutationRequest, succeeded bool) error
-}
diff --git a/internal/repository/postgres/channel.go b/internal/repository/postgres/channel.go
deleted file mode 100644
index 6c57ca3..0000000
--- a/internal/repository/postgres/channel.go
+++ /dev/null
@@ -1,277 +0,0 @@
-package postgres
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "time"
-
- "github.com/google/uuid"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
-)
-
-type channelTx struct{ queries *db.Queries }
-
-func (s *Store) WithinChannelTx(ctx context.Context, fn func(repository.ChannelMutationStore) error) error {
- tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback(ctx) }()
- if err := fn(&channelTx{queries: s.queries.WithTx(tx)}); err != nil {
- return err
- }
- return tx.Commit(ctx)
-}
-
-func (s *Store) CreateChannel(ctx context.Context, value model.ChannelInstance) (model.ChannelInstance, error) {
- return createChannel(ctx, s.queries, value)
-}
-func (t *channelTx) CreateChannel(ctx context.Context, value model.ChannelInstance) (model.ChannelInstance, error) {
- return createChannel(ctx, t.queries, value)
-}
-func createChannel(ctx context.Context, q *db.Queries, value model.ChannelInstance) (model.ChannelInstance, error) {
- configJSON, err := json.Marshal(value.Config)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- row, err := q.CreateChannelInstance(ctx, db.CreateChannelInstanceParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), Type: value.Type, Name: value.Name, Config: configJSON, SecretRef: nullableUUID(value.SecretRef)})
- if err != nil {
- return model.ChannelInstance{}, mapMetadataError(err)
- }
- return channelFromDB(row)
-}
-
-func (s *Store) ListChannels(ctx context.Context, workspaceID uuid.UUID) ([]model.ChannelInstance, error) {
- rows, err := s.queries.ListChannelInstances(ctx, uuidToPG(workspaceID))
- if err != nil {
- return nil, err
- }
- return channelsFromDB(rows)
-}
-func (s *Store) GetChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) {
- row, err := s.queries.GetChannelInstance(ctx, db.GetChannelInstanceParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.ChannelInstance{}, mapMetadataError(err)
- }
- return channelFromDB(row)
-}
-func (s *Store) ListEnabledChannels(ctx context.Context) ([]model.ChannelInstance, error) {
- rows, err := s.queries.ListEnabledChannelInstances(ctx)
- if err != nil {
- return nil, err
- }
- return channelsFromDB(rows)
-}
-
-func (s *Store) UpdateChannel(ctx context.Context, value model.ChannelInstance, expected int64) (model.ChannelInstance, error) {
- return updateChannel(ctx, s.queries, value, expected)
-}
-func (t *channelTx) UpdateChannel(ctx context.Context, value model.ChannelInstance, expected int64) (model.ChannelInstance, error) {
- return updateChannel(ctx, t.queries, value, expected)
-}
-func updateChannel(ctx context.Context, q *db.Queries, value model.ChannelInstance, expected int64) (model.ChannelInstance, error) {
- configJSON, err := json.Marshal(value.Config)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- row, err := q.UpdateChannelInstance(ctx, db.UpdateChannelInstanceParams{Name: value.Name, Config: configJSON, SecretRef: nullableUUID(value.SecretRef), ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), ExpectedVersion: expected})
- if err != nil {
- return model.ChannelInstance{}, mapMetadataError(err)
- }
- return channelFromDB(row)
-}
-
-func (s *Store) SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) {
- return setChannelEnabled(ctx, s.queries, workspaceID, id, enabled)
-}
-func (t *channelTx) SetChannelEnabled(ctx context.Context, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) {
- return setChannelEnabled(ctx, t.queries, workspaceID, id, enabled)
-}
-func setChannelEnabled(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID, enabled bool) (model.ChannelInstance, error) {
- row, err := q.SetChannelEnabled(ctx, db.SetChannelEnabledParams{Enabled: enabled, ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.ChannelInstance{}, mapMetadataError(err)
- }
- return channelFromDB(row)
-}
-
-func (s *Store) SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) {
- return softDeleteChannel(ctx, s.queries, workspaceID, id)
-}
-func (t *channelTx) SoftDeleteChannel(ctx context.Context, workspaceID, id uuid.UUID) (model.ChannelInstance, error) {
- return softDeleteChannel(ctx, t.queries, workspaceID, id)
-}
-func softDeleteChannel(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.ChannelInstance, error) {
- row, err := q.SoftDeleteChannel(ctx, db.SoftDeleteChannelParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.ChannelInstance{}, mapMetadataError(err)
- }
- return channelFromDB(row)
-}
-
-func (s *Store) AcquireChannelLease(ctx context.Context, instanceID uuid.UUID, owner string, until time.Time) (model.ChannelLease, error) {
- row, err := s.queries.AcquireChannelLease(ctx, db.AcquireChannelLeaseParams{ChannelInstanceID: uuidToPG(instanceID), Owner: owner, LeaseUntil: timeToPG(until)})
- if err != nil {
- return model.ChannelLease{}, mapMetadataError(err)
- }
- return model.ChannelLease{ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), Owner: row.Owner, LeaseUntil: row.LeaseUntil.Time, FencingToken: row.FencingToken}, nil
-}
-func (s *Store) RenewChannelLease(ctx context.Context, lease model.ChannelLease, until time.Time) error {
- rows, err := s.queries.RenewChannelLease(ctx, db.RenewChannelLeaseParams{LeaseUntil: timeToPG(until), ChannelInstanceID: uuidToPG(lease.ChannelInstanceID), Owner: lease.Owner, FencingToken: lease.FencingToken})
- return requireAffected(rows, err)
-}
-func (s *Store) ReleaseChannelLease(ctx context.Context, lease model.ChannelLease) error {
- return s.queries.ReleaseChannelLease(ctx, db.ReleaseChannelLeaseParams{ChannelInstanceID: uuidToPG(lease.ChannelInstanceID), Owner: lease.Owner, FencingToken: lease.FencingToken})
-}
-
-func (s *Store) SetChannelStatus(ctx context.Context, status model.ChannelRuntimeStatus) error {
- message := status.LastErrorMessage
- if len(message) > 2048 {
- message = message[:2048]
- }
- rows, err := s.queries.SetChannelRuntimeStatus(ctx, db.SetChannelRuntimeStatusParams{ChannelInstanceID: uuidToPG(status.ChannelInstanceID), State: status.State, BackendInstanceID: status.BackendInstanceID, FencingToken: status.FencingToken, LastConnectedAt: nullableTime(status.LastConnectedAt), LastErrorCode: status.LastErrorCode, LastErrorMessage: message})
- if err != nil {
- return err
- }
- if rows == 0 {
- return repository.ErrLeaseLost
- }
- return nil
-}
-func (s *Store) GetChannelStatus(ctx context.Context, instanceID uuid.UUID) (model.ChannelRuntimeStatus, error) {
- row, err := s.queries.GetChannelRuntimeStatus(ctx, uuidToPG(instanceID))
- if err != nil {
- return model.ChannelRuntimeStatus{}, mapMetadataError(err)
- }
- return model.ChannelRuntimeStatus{ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), State: row.State, BackendInstanceID: row.BackendInstanceID, FencingToken: row.FencingToken, LastConnectedAt: timePointer(row.LastConnectedAt), LastErrorCode: row.LastErrorCode, LastErrorMessage: row.LastErrorMessage, UpdatedAt: row.UpdatedAt.Time}, nil
-}
-
-func (s *Store) AcceptInbound(ctx context.Context, instance model.ChannelInstance, message channelcore.InboundMessage) (bool, error) {
- tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
- if err != nil {
- return false, err
- }
- defer func() { _ = tx.Rollback(ctx) }()
- q := s.queries.WithTx(tx)
- payload, err := json.Marshal(struct {
- Content channelcore.MessageContent `json:"content"`
- Mentioned bool `json:"mentioned"`
- ReplyToMessageID string `json:"replyToMessageId,omitempty"`
- Attributes map[string]string `json:"attributes,omitempty"`
- }{message.Content, message.Mentioned, message.ReplyToMessageID, message.Attributes})
- if err != nil {
- return false, err
- }
- hash := sha256.Sum256(payload)
- if _, err := q.InsertInboxEvent(ctx, db.InsertInboxEventParams{ChannelInstanceID: uuidToPG(instance.ID), ExternalEventID: message.ExternalEventID, PayloadHash: hex.EncodeToString(hash[:])}); errors.Is(err, pgx.ErrNoRows) {
- return false, tx.Commit(ctx)
- } else if err != nil {
- return false, err
- }
- conversationID, err := uuid.NewV7()
- if err != nil {
- return false, err
- }
- conversation, err := q.UpsertIMConversation(ctx, db.UpsertIMConversationParams{ID: uuidToPG(conversationID), WorkspaceID: uuidToPG(instance.WorkspaceID), ChannelInstanceID: uuidToPG(instance.ID), ExternalID: message.Conversation.ID, Type: message.Conversation.Type, Title: message.Attributes["conversation_title"]})
- if err != nil {
- return false, err
- }
- senderID, err := uuid.NewV7()
- if err != nil {
- return false, err
- }
- sender, err := q.UpsertIMSender(ctx, db.UpsertIMSenderParams{ID: uuidToPG(senderID), WorkspaceID: uuidToPG(instance.WorkspaceID), ChannelType: instance.Type, CanonicalID: message.Sender.CanonicalID, DisplayName: message.Sender.DisplayName})
- if err != nil {
- return false, err
- }
- messageID, err := uuid.NewV7()
- if err != nil {
- return false, err
- }
- inserted, err := q.InsertIMMessage(ctx, db.InsertIMMessageParams{ID: uuidToPG(messageID), WorkspaceID: uuidToPG(instance.WorkspaceID), ChannelInstanceID: uuidToPG(instance.ID), ConversationID: conversation.ID, SenderID: sender.ID, ExternalMessageID: message.ExternalMessageID, Content: payload, OccurredAt: timeToPG(message.OccurredAt)})
- if errors.Is(err, pgx.ErrNoRows) {
- return false, tx.Commit(ctx)
- } else if err != nil {
- return false, err
- }
- eventID, err := uuid.NewV7()
- if err != nil {
- return false, err
- }
- eventPayload, _ := json.Marshal(map[string]string{"messageId": uuidFromPG(inserted.ID).String(), "channelInstanceId": instance.ID.String()})
- if err := q.AppendOutboxEvent(ctx, db.AppendOutboxEventParams{ID: uuidToPG(eventID), WorkspaceID: uuidToPG(instance.WorkspaceID), Aggregate: "im_message", AggregateID: inserted.ID, EventType: "im.message_received", Payload: eventPayload, TraceContext: encodeTraceContext(ctx, s.propagator)}); err != nil {
- return false, err
- }
- return true, tx.Commit(ctx)
-}
-
-func (s *Store) ListMessages(ctx context.Context, workspaceID uuid.UUID, channelID, conversationID *uuid.UUID, cursor repository.MessageCursor, pageSize int32) ([]model.IMMessage, error) {
- params := db.ListIMMessagesParams{WorkspaceID: uuidToPG(workspaceID), ChannelInstanceID: nullableUUID(channelID), ConversationID: nullableUUID(conversationID), PageSize: pageSize}
- if cursor.BeforeTime != nil {
- params.BeforeTime = nullableTime(cursor.BeforeTime)
- params.BeforeID = nullableUUID(cursor.BeforeID)
- }
- rows, err := s.queries.ListIMMessages(ctx, params)
- if err != nil {
- return nil, err
- }
- result := make([]model.IMMessage, 0, len(rows))
- for _, row := range rows {
- result = append(result, model.IMMessage{ID: uuidFromPG(row.ID), ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), ChannelName: row.ChannelName, ChannelType: row.ChannelType, ConversationID: uuidFromPG(row.ConversationID), ConversationExternalID: row.ConversationExternalID, ConversationType: row.ConversationType, SenderCanonicalID: row.SenderCanonicalID, SenderDisplayName: row.SenderDisplayName, ExternalMessageID: row.ExternalMessageID, Content: row.Content, OccurredAt: row.OccurredAt.Time, ReceivedAt: row.ReceivedAt.Time})
- }
- return result, nil
-}
-func (s *Store) ListConversations(ctx context.Context, workspaceID uuid.UUID, pageSize int32) ([]model.IMConversation, error) {
- rows, err := s.queries.ListIMConversations(ctx, db.ListIMConversationsParams{WorkspaceID: uuidToPG(workspaceID), PageSize: pageSize})
- if err != nil {
- return nil, err
- }
- result := make([]model.IMConversation, 0, len(rows))
- for _, row := range rows {
- result = append(result, model.IMConversation{ID: uuidFromPG(row.ID), ChannelInstanceID: uuidFromPG(row.ChannelInstanceID), ChannelName: row.ChannelName, ChannelType: row.ChannelType, ExternalID: row.ExternalID, Type: row.Type, Title: row.Title, UpdatedAt: row.UpdatedAt.Time})
- }
- return result, nil
-}
-
-func (t *channelTx) AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error {
- return appendAudit(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, metadata)
-}
-func (t *channelTx) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error {
- return appendAuditResult(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata)
-}
-func channelFromDB(row db.ChannelInstance) (model.ChannelInstance, error) {
- var config model.ChannelConfig
- if err := json.Unmarshal(row.Config, &config); err != nil {
- return model.ChannelInstance{}, err
- }
- secret := uuidPointer(row.SecretRef)
- return model.ChannelInstance{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Type: row.Type, Name: row.Name, Enabled: row.Enabled, Config: config, SecretRef: secret, SecretConfigured: secret != nil, ConfigVersion: row.ConfigVersion, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time}, nil
-}
-func channelsFromDB(rows []db.ChannelInstance) ([]model.ChannelInstance, error) {
- result := make([]model.ChannelInstance, 0, len(rows))
- for _, row := range rows {
- value, err := channelFromDB(row)
- if err != nil {
- return nil, err
- }
- result = append(result, value)
- }
- return result, nil
-}
-func nullableTime(value *time.Time) pgtype.Timestamptz {
- if value == nil {
- return pgtype.Timestamptz{}
- }
- return timeToPG(*value)
-}
-
-var _ repository.ChannelStore = (*Store)(nil)
-var _ repository.ChannelMutationStore = (*channelTx)(nil)
diff --git a/internal/repository/postgres/identity.go b/internal/repository/postgres/identity.go
deleted file mode 100644
index 1a8af8c..0000000
--- a/internal/repository/postgres/identity.go
+++ /dev/null
@@ -1,458 +0,0 @@
-package postgres
-
-import (
- "context"
- "encoding/json"
- "errors"
- "time"
-
- "github.com/google/uuid"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc"
-)
-
-type identityTx struct {
- queries *db.Queries
-}
-
-func (s *Store) WithinIdentityTx(ctx context.Context, fn func(repository.IdentityStore) error) error {
- tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback(ctx) }()
- if err := fn(&identityTx{queries: s.queries.WithTx(tx)}); err != nil {
- return err
- }
- return tx.Commit(ctx)
-}
-
-func (s *Store) UpsertUser(ctx context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) {
- return upsertUser(ctx, s.queries, id, identity)
-}
-
-func (s *Store) GetUserByExternalIdentity(ctx context.Context, issuer, subject string) (model.User, error) {
- return getUserByExternalIdentity(ctx, s.queries, issuer, subject)
-}
-
-func (s *Store) GetUserForUpdate(ctx context.Context, userID uuid.UUID) (model.User, error) {
- return getUserForUpdate(ctx, s.queries, userID)
-}
-
-func (s *Store) ActivateUser(ctx context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) {
- return activateUser(ctx, s.queries, userID, termsVersion, privacyVersion)
-}
-
-func (s *Store) GetPersonalWorkspace(ctx context.Context, userID uuid.UUID) (model.Workspace, error) {
- return getPersonalWorkspace(ctx, s.queries, userID)
-}
-
-func (s *Store) CreateWorkspace(ctx context.Context, workspace model.Workspace) (model.Workspace, error) {
- return createWorkspace(ctx, s.queries, workspace)
-}
-
-func (s *Store) AddWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error {
- return addWorkspaceMember(ctx, s.queries, workspaceID, userID, role)
-}
-
-func (s *Store) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]model.Workspace, error) {
- return listWorkspaces(ctx, s.queries, userID)
-}
-
-func (s *Store) GetWorkspaceMembership(ctx context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) {
- return getWorkspaceMembership(ctx, s.queries, workspaceID, userID)
-}
-func (s *Store) UpdateWorkspace(ctx context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) {
- return updateWorkspace(ctx, s.queries, workspaceID, name)
-}
-func (s *Store) ListWorkspaceMembers(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) {
- return listWorkspaceMembers(ctx, s.queries, workspaceID)
-}
-func (s *Store) UpsertWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error {
- return upsertWorkspaceMember(ctx, s.queries, workspaceID, userID, role)
-}
-func (s *Store) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID) error {
- return deleteWorkspaceMember(ctx, s.queries, workspaceID, userID)
-}
-func (s *Store) CreateWorkspaceInvitation(ctx context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) {
- return createWorkspaceInvitation(ctx, s.queries, invitation, tokenHash)
-}
-func (s *Store) ListWorkspaceInvitations(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) {
- return listWorkspaceInvitations(ctx, s.queries, workspaceID)
-}
-func (s *Store) GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) {
- return getWorkspaceInvitationForUpdate(ctx, s.queries, tokenHash)
-}
-func (s *Store) AcceptWorkspaceInvitation(ctx context.Context, invitationID, userID uuid.UUID) error {
- return acceptWorkspaceInvitation(ctx, s.queries, invitationID, userID)
-}
-func (s *Store) RevokeWorkspaceInvitation(ctx context.Context, invitationID uuid.UUID) error {
- return revokeWorkspaceInvitation(ctx, s.queries, invitationID)
-}
-func (s *Store) AppendAccountAudit(ctx context.Context, entry model.AccountAuditLog) error {
- return appendAccountAudit(ctx, s.queries, entry)
-}
-func (s *Store) AppendIdentityAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error {
- return appendAudit(ctx, s.queries, workspaceID, actorID, action, resourceType, resourceID, metadata)
-}
-
-func (t *identityTx) WithinIdentityTx(_ context.Context, fn func(repository.IdentityStore) error) error {
- return fn(t)
-}
-
-func (t *identityTx) UpsertUser(ctx context.Context, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) {
- return upsertUser(ctx, t.queries, id, identity)
-}
-
-func (t *identityTx) GetUserByExternalIdentity(ctx context.Context, issuer, subject string) (model.User, error) {
- return getUserByExternalIdentity(ctx, t.queries, issuer, subject)
-}
-
-func (t *identityTx) GetUserForUpdate(ctx context.Context, userID uuid.UUID) (model.User, error) {
- return getUserForUpdate(ctx, t.queries, userID)
-}
-
-func (t *identityTx) ActivateUser(ctx context.Context, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) {
- return activateUser(ctx, t.queries, userID, termsVersion, privacyVersion)
-}
-
-func (t *identityTx) GetPersonalWorkspace(ctx context.Context, userID uuid.UUID) (model.Workspace, error) {
- return getPersonalWorkspace(ctx, t.queries, userID)
-}
-
-func (t *identityTx) CreateWorkspace(ctx context.Context, workspace model.Workspace) (model.Workspace, error) {
- return createWorkspace(ctx, t.queries, workspace)
-}
-
-func (t *identityTx) AddWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error {
- return addWorkspaceMember(ctx, t.queries, workspaceID, userID, role)
-}
-
-func (t *identityTx) ListWorkspaces(ctx context.Context, userID uuid.UUID) ([]model.Workspace, error) {
- return listWorkspaces(ctx, t.queries, userID)
-}
-
-func (t *identityTx) GetWorkspaceMembership(ctx context.Context, workspaceID, userID uuid.UUID) (model.Workspace, error) {
- return getWorkspaceMembership(ctx, t.queries, workspaceID, userID)
-}
-func (t *identityTx) UpdateWorkspace(ctx context.Context, workspaceID uuid.UUID, name string) (model.Workspace, error) {
- return updateWorkspace(ctx, t.queries, workspaceID, name)
-}
-func (t *identityTx) ListWorkspaceMembers(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) {
- return listWorkspaceMembers(ctx, t.queries, workspaceID)
-}
-func (t *identityTx) UpsertWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID, role string) error {
- return upsertWorkspaceMember(ctx, t.queries, workspaceID, userID, role)
-}
-func (t *identityTx) DeleteWorkspaceMember(ctx context.Context, workspaceID, userID uuid.UUID) error {
- return deleteWorkspaceMember(ctx, t.queries, workspaceID, userID)
-}
-func (t *identityTx) CreateWorkspaceInvitation(ctx context.Context, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) {
- return createWorkspaceInvitation(ctx, t.queries, invitation, tokenHash)
-}
-func (t *identityTx) ListWorkspaceInvitations(ctx context.Context, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) {
- return listWorkspaceInvitations(ctx, t.queries, workspaceID)
-}
-func (t *identityTx) GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (model.WorkspaceInvitation, error) {
- return getWorkspaceInvitationForUpdate(ctx, t.queries, tokenHash)
-}
-func (t *identityTx) AcceptWorkspaceInvitation(ctx context.Context, invitationID, userID uuid.UUID) error {
- return acceptWorkspaceInvitation(ctx, t.queries, invitationID, userID)
-}
-func (t *identityTx) RevokeWorkspaceInvitation(ctx context.Context, invitationID uuid.UUID) error {
- return revokeWorkspaceInvitation(ctx, t.queries, invitationID)
-}
-func (t *identityTx) AppendAccountAudit(ctx context.Context, entry model.AccountAuditLog) error {
- return appendAccountAudit(ctx, t.queries, entry)
-}
-func (t *identityTx) AppendIdentityAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error {
- return appendAudit(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, metadata)
-}
-func (t *identityTx) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error {
- return appendAuditResult(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata)
-}
-
-func upsertUser(ctx context.Context, queries *db.Queries, id uuid.UUID, identity model.ExternalIdentity) (model.User, error) {
- row, err := queries.UpsertUser(ctx, db.UpsertUserParams{
- ID: uuidToPG(id), Issuer: identity.Issuer, ExternalSubject: identity.Subject,
- Username: identity.Username, Email: identity.Email, DisplayName: identity.DisplayName,
- })
- if err != nil {
- return model.User{}, err
- }
- return userFromDB(row), nil
-}
-
-func getUserByExternalIdentity(ctx context.Context, queries *db.Queries, issuer, subject string) (model.User, error) {
- row, err := queries.GetUserByExternalIdentity(ctx, db.GetUserByExternalIdentityParams{Issuer: issuer, ExternalSubject: subject})
- if errors.Is(err, pgx.ErrNoRows) {
- return model.User{}, repository.ErrNotFound
- }
- if err != nil {
- return model.User{}, err
- }
- return userFromDB(row), nil
-}
-
-func getUserForUpdate(ctx context.Context, queries *db.Queries, userID uuid.UUID) (model.User, error) {
- row, err := queries.GetUserForUpdate(ctx, uuidToPG(userID))
- if errors.Is(err, pgx.ErrNoRows) {
- return model.User{}, repository.ErrNotFound
- }
- if err != nil {
- return model.User{}, err
- }
- return userFromDB(row), nil
-}
-
-func activateUser(ctx context.Context, queries *db.Queries, userID uuid.UUID, termsVersion, privacyVersion string) (model.User, error) {
- row, err := queries.ActivateUser(ctx, db.ActivateUserParams{ID: uuidToPG(userID), TermsVersion: termsVersion, PrivacyVersion: privacyVersion})
- if errors.Is(err, pgx.ErrNoRows) {
- return model.User{}, repository.ErrConflict
- }
- if err != nil {
- return model.User{}, err
- }
- return userFromDB(row), nil
-}
-
-func getPersonalWorkspace(ctx context.Context, queries *db.Queries, userID uuid.UUID) (model.Workspace, error) {
- row, err := queries.GetPersonalWorkspaceForUser(ctx, uuidToPG(userID))
- if errors.Is(err, pgx.ErrNoRows) {
- return model.Workspace{}, repository.ErrNotFound
- }
- if err != nil {
- return model.Workspace{}, err
- }
- return workspaceFromDB(row, "owner"), nil
-}
-
-func createWorkspace(ctx context.Context, queries *db.Queries, workspace model.Workspace) (model.Workspace, error) {
- row, err := queries.CreateWorkspace(ctx, db.CreateWorkspaceParams{
- ID: uuidToPG(workspace.ID), Name: workspace.Name, Slug: workspace.Slug,
- Kind: workspace.Kind, CreatedBy: uuidToPG(workspace.CreatedBy),
- })
- if err != nil {
- return model.Workspace{}, err
- }
- return workspaceFromDB(row, workspace.Role), nil
-}
-
-func addWorkspaceMember(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID, role string) error {
- _, err := queries.AddWorkspaceMember(ctx, db.AddWorkspaceMemberParams{
- WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID), Role: role,
- })
- if errors.Is(err, pgx.ErrNoRows) {
- return repository.ErrConflict
- }
- return err
-}
-
-func listWorkspaces(ctx context.Context, queries *db.Queries, userID uuid.UUID) ([]model.Workspace, error) {
- rows, err := queries.ListWorkspacesForUser(ctx, uuidToPG(userID))
- if err != nil {
- return nil, err
- }
- result := make([]model.Workspace, 0, len(rows))
- for _, row := range rows {
- result = append(result, model.Workspace{
- ID: uuidFromPG(row.ID), Name: row.Name, Slug: row.Slug, Kind: row.Kind,
- CreatedBy: uuidFromPG(row.CreatedBy), Role: row.Role,
- CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
- })
- }
- return result, nil
-}
-
-func getWorkspaceMembership(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID) (model.Workspace, error) {
- row, err := queries.GetWorkspaceMembership(ctx, db.GetWorkspaceMembershipParams{
- WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID),
- })
- if errors.Is(err, pgx.ErrNoRows) {
- return model.Workspace{}, repository.ErrNotFound
- }
- if err != nil {
- return model.Workspace{}, err
- }
- return model.Workspace{
- ID: uuidFromPG(row.ID), Name: row.Name, Slug: row.Slug, Kind: row.Kind,
- CreatedBy: uuidFromPG(row.CreatedBy), Role: row.Role,
- CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
- }, nil
-}
-
-func updateWorkspace(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID, name string) (model.Workspace, error) {
- row, err := queries.UpdateWorkspace(ctx, db.UpdateWorkspaceParams{Name: name, ID: uuidToPG(workspaceID)})
- if err != nil {
- return model.Workspace{}, mapMetadataError(err)
- }
- return workspaceFromDB(row, ""), nil
-}
-
-func listWorkspaceMembers(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) {
- rows, err := queries.ListWorkspaceMembers(ctx, uuidToPG(workspaceID))
- if err != nil {
- return nil, err
- }
- result := make([]model.WorkspaceMember, 0, len(rows))
- for _, row := range rows {
- result = append(result, model.WorkspaceMember{
- WorkspaceID: uuidFromPG(row.WorkspaceID), UserID: uuidFromPG(row.UserID), Role: row.Role,
- Username: row.Username, Email: row.Email, DisplayName: row.DisplayName,
- CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
- })
- }
- return result, nil
-}
-
-func upsertWorkspaceMember(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID, role string) error {
- return queries.UpsertWorkspaceMember(ctx, db.UpsertWorkspaceMemberParams{WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID), Role: role})
-}
-
-func deleteWorkspaceMember(ctx context.Context, queries *db.Queries, workspaceID, userID uuid.UUID) error {
- rows, err := queries.DeleteWorkspaceMember(ctx, db.DeleteWorkspaceMemberParams{WorkspaceID: uuidToPG(workspaceID), UserID: uuidToPG(userID)})
- if err != nil {
- return err
- }
- if rows == 0 {
- return repository.ErrNotFound
- }
- return nil
-}
-
-func createWorkspaceInvitation(ctx context.Context, queries *db.Queries, invitation model.WorkspaceInvitation, tokenHash []byte) (model.WorkspaceInvitation, error) {
- row, err := queries.CreateWorkspaceInvitation(ctx, db.CreateWorkspaceInvitationParams{
- ID: uuidToPG(invitation.ID), WorkspaceID: uuidToPG(invitation.WorkspaceID),
- EmailNormalized: invitation.Email, Role: invitation.Role, TokenHash: tokenHash,
- InvitedBy: uuidToPG(invitation.InvitedBy), ExpiresAt: pgtype.Timestamptz{Time: invitation.ExpiresAt, Valid: true},
- })
- if err != nil {
- return model.WorkspaceInvitation{}, mapMetadataError(err)
- }
- return invitationFromDB(row), nil
-}
-
-func listWorkspaceInvitations(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) {
- rows, err := queries.ListWorkspaceInvitations(ctx, uuidToPG(workspaceID))
- if err != nil {
- return nil, err
- }
- result := make([]model.WorkspaceInvitation, 0, len(rows))
- for _, row := range rows {
- result = append(result, invitationFromDB(row))
- }
- return result, nil
-}
-
-func getWorkspaceInvitationForUpdate(ctx context.Context, queries *db.Queries, tokenHash []byte) (model.WorkspaceInvitation, error) {
- row, err := queries.GetWorkspaceInvitationForUpdate(ctx, tokenHash)
- if errors.Is(err, pgx.ErrNoRows) {
- return model.WorkspaceInvitation{}, repository.ErrNotFound
- }
- if err != nil {
- return model.WorkspaceInvitation{}, err
- }
- return invitationFromDB(row), nil
-}
-
-func acceptWorkspaceInvitation(ctx context.Context, queries *db.Queries, invitationID, userID uuid.UUID) error {
- rows, err := queries.AcceptWorkspaceInvitation(ctx, db.AcceptWorkspaceInvitationParams{ID: uuidToPG(invitationID), AcceptedBy: uuidToPG(userID)})
- if err != nil {
- return err
- }
- if rows == 0 {
- return repository.ErrConflict
- }
- return nil
-}
-
-func revokeWorkspaceInvitation(ctx context.Context, queries *db.Queries, invitationID uuid.UUID) error {
- rows, err := queries.RevokeWorkspaceInvitation(ctx, uuidToPG(invitationID))
- if err != nil {
- return err
- }
- if rows == 0 {
- return repository.ErrConflict
- }
- return nil
-}
-
-func appendAccountAudit(ctx context.Context, queries *db.Queries, entry model.AccountAuditLog) error {
- metadata, err := json.Marshal(entry.Metadata)
- if err != nil {
- return err
- }
- actor := pgtype.UUID{}
- if entry.ActorUserID != nil {
- actor = uuidToPG(*entry.ActorUserID)
- }
- return queries.AppendAccountAudit(ctx, db.AppendAccountAuditParams{
- ID: uuidToPG(entry.ID), UserID: uuidToPG(entry.UserID), ActorUserID: actor,
- Action: entry.Action, Result: entry.Result, Provider: entry.Provider,
- RequestID: entry.RequestID, Metadata: metadata,
- OccurredAt: pgtype.Timestamptz{Time: entry.OccurredAt, Valid: true},
- })
-}
-
-func userFromDB(row db.User) model.User {
- return model.User{
- ID: uuidFromPG(row.ID), Issuer: row.Issuer, ExternalSubject: row.ExternalSubject,
- Username: row.Username, Email: row.Email, DisplayName: row.DisplayName,
- Status: row.Status, TermsVersion: row.TermsVersion, PrivacyVersion: row.PrivacyVersion,
- ActivatedAt: timeFromPG(row.ActivatedAt), SuspendedAt: timeFromPG(row.SuspendedAt),
- LastSeenAt: timeFromPG(row.LastSeenAt), AgreementsAcceptedAt: timeFromPG(row.AgreementsAcceptedAt),
- DeletedAt: timeFromPG(row.DeletedAt),
- CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
- }
-}
-
-func invitationFromDB(row db.WorkspaceInvitation) model.WorkspaceInvitation {
- var acceptedBy *uuid.UUID
- if row.AcceptedBy.Valid {
- value := uuidFromPG(row.AcceptedBy)
- acceptedBy = &value
- }
- return model.WorkspaceInvitation{
- ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Email: row.EmailNormalized,
- Role: row.Role, InvitedBy: uuidFromPG(row.InvitedBy), ExpiresAt: row.ExpiresAt.Time,
- AcceptedAt: timeFromPG(row.AcceptedAt), AcceptedBy: acceptedBy, RevokedAt: timeFromPG(row.RevokedAt),
- CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
- }
-}
-
-func timeFromPG(value pgtype.Timestamptz) *time.Time {
- if !value.Valid {
- return nil
- }
- result := value.Time
- return &result
-}
-
-func workspaceFromDB(row db.Workspace, role string) model.Workspace {
- return model.Workspace{
- ID: uuidFromPG(row.ID), Name: row.Name, Slug: row.Slug, Kind: row.Kind,
- CreatedBy: uuidFromPG(row.CreatedBy), Role: role,
- CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time,
- }
-}
-
-func uuidToPG(value uuid.UUID) pgtype.UUID {
- return pgtype.UUID{Bytes: value, Valid: true}
-}
-
-func nullableUUIDToPG(value uuid.UUID) pgtype.UUID {
- if value == uuid.Nil {
- return pgtype.UUID{}
- }
- return uuidToPG(value)
-}
-
-func uuidFromPG(value pgtype.UUID) uuid.UUID {
- if !value.Valid {
- return uuid.Nil
- }
- return uuid.UUID(value.Bytes)
-}
diff --git a/internal/repository/postgres/integration_test.go b/internal/repository/postgres/integration_test.go
deleted file mode 100644
index bcb187d..0000000
--- a/internal/repository/postgres/integration_test.go
+++ /dev/null
@@ -1,691 +0,0 @@
-//go:build integration
-
-package postgres
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "sync"
- "testing"
- "time"
-
- "github.com/google/uuid"
- channelmanager "github.com/mooncode-ai/mooncode/internal/channel"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/jobs"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
- "github.com/mooncode-ai/mooncode/internal/service"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
- "github.com/rs/zerolog"
- "github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
-)
-
-func TestPostgreSQLIntegration(t *testing.T) {
- store := startPostgreSQL(t)
-
- t.Run("empty database migrates through current version", func(t *testing.T) {
- var version int
- var dirty bool
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT version, dirty FROM schema_migrations").Scan(&version, &dirty))
- require.Equal(t, 1, version)
- require.False(t, dirty)
- })
-
- t.Run("workspace scoped repository queries", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, firstWorkspace := seedTenant(t, store, "first")
- _, secondWorkspace := seedTenant(t, store, "second")
- first := integrationCreateRepository(t, store, firstWorkspace, "https://example.com/first.git")
- _ = integrationCreateRepository(t, store, secondWorkspace, "https://example.com/second.git")
-
- _, err := store.GetRepository(t.Context(), secondWorkspace, first.ID)
- require.ErrorIs(t, err, repository.ErrNotFound)
- items, err := store.ListRepositories(t.Context(), firstWorkspace)
- require.NoError(t, err)
- require.Len(t, items, 1)
- require.Equal(t, first.ID, items[0].ID)
- })
-
- t.Run("workspace scope covers connections jobs channels and messages", func(t *testing.T) {
- resetPostgreSQL(t, store)
- userID, firstWorkspace := seedTenant(t, store, "scope-first")
- _, secondWorkspace := seedTenant(t, store, "scope-second")
- connection, err := store.CreateSCMConnection(t.Context(), model.SCMConnection{ID: uuid.New(), WorkspaceID: firstWorkspace, Type: "generic", Name: "private", BaseURL: "https://git.example.com", AuthType: "token"})
- require.NoError(t, err)
- repositoryValue := integrationCreateRepository(t, store, firstWorkspace, "https://git.example.com/repository.git")
- job := integrationCreateJob(t, store, firstWorkspace, "scope-job", "test.job", json.RawMessage(`{}`))
- instance := seedChannel(t, store, firstWorkspace)
- inserted, err := store.AcceptInbound(t.Context(), instance, channelcore.InboundMessage{
- ExternalEventID: "scope-event", ExternalMessageID: "scope-message",
- Conversation: channelcore.ConversationRef{ID: "scope-conversation", Type: "direct"},
- Sender: channelcore.SenderInfo{PlatformID: "scope-sender", CanonicalID: "fake:scope-sender"},
- Content: channelcore.MessageContent{Type: "text", Text: "tenant private"}, OccurredAt: time.Now().UTC(),
- })
- require.NoError(t, err)
- require.True(t, inserted)
- require.NoError(t, store.AppendAudit(t.Context(), firstWorkspace, userID, "scope.test", "repository", repositoryValue.ID, []byte(`{}`)))
-
- _, err = store.GetSCMConnection(t.Context(), secondWorkspace, connection.ID)
- require.ErrorIs(t, err, repository.ErrNotFound)
- connections, err := store.ListSCMConnections(t.Context(), secondWorkspace)
- require.NoError(t, err)
- require.Empty(t, connections)
- _, err = store.GetJob(t.Context(), secondWorkspace, job.ID)
- require.ErrorIs(t, err, repository.ErrNotFound)
- _, err = store.GetChannel(t.Context(), secondWorkspace, instance.ID)
- require.ErrorIs(t, err, repository.ErrNotFound)
- channels, err := store.ListChannels(t.Context(), secondWorkspace)
- require.NoError(t, err)
- require.Empty(t, channels)
- messages, err := store.ListMessages(t.Context(), secondWorkspace, nil, nil, repository.MessageCursor{}, 10)
- require.NoError(t, err)
- require.Empty(t, messages)
- conversations, err := store.ListConversations(t.Context(), secondWorkspace, 10)
- require.NoError(t, err)
- require.Empty(t, conversations)
- logs, err := store.ListAuditLogs(t.Context(), secondWorkspace, repository.TimeCursor{}, 10)
- require.NoError(t, err)
- require.Empty(t, logs)
- })
-
- t.Run("job claim skip locked and lease fencing", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "jobs")
- job := integrationCreateJob(t, store, workspaceID, "claim-once", "test.job", json.RawMessage(`{}`))
-
- claimed := concurrentlyClaimJobs(t, store, 8, time.Now().Add(time.Minute))
- require.Len(t, claimed, 1)
- require.Equal(t, job.ID, claimed[0].ID)
-
- resetPostgreSQL(t, store)
- _, workspaceID = seedTenant(t, store, "takeover")
- job = integrationCreateJob(t, store, workspaceID, "take-over", "test.job", json.RawMessage(`{}`))
- first, err := store.ClaimJob(t.Context(), []string{"test.job"}, "backend-one", time.Now().Add(-time.Second))
- require.NoError(t, err)
- second, err := store.ClaimJob(t.Context(), []string{"test.job"}, "backend-two", time.Now().Add(time.Minute))
- require.NoError(t, err)
- require.Equal(t, job.ID, second.ID)
- require.Greater(t, second.FencingToken, first.FencingToken)
- require.ErrorIs(t, store.CompleteJob(t.Context(), first), repository.ErrLeaseLost)
- require.NoError(t, store.CompleteJob(t.Context(), second))
- })
-
- t.Run("job runner restart reclaims an expired attempt", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "runner-restart")
- job := integrationCreateJob(t, store, workspaceID, "runner-restart", "test.restart", json.RawMessage(`{}`))
- cfg, err := config.NewLoader().Load("", nil)
- require.NoError(t, err)
- cfg.Jobs.PollInterval = 10 * time.Millisecond
- cfg.Jobs.RenewInterval = 50 * time.Millisecond
- cfg.Jobs.LeaseDuration = time.Second
- started := make(chan struct{})
- first, err := jobs.NewRunner(store, "backend-before-restart", cfg, zerolog.New(io.Discard), jobs.WithHandler(&integrationJobHandler{jobType: "test.restart", started: started, waitForCancel: true}))
- require.NoError(t, err)
- firstContext, stopFirst := context.WithCancel(t.Context())
- firstDone := make(chan error, 1)
- go func() { firstDone <- first.Run(firstContext) }()
- select {
- case <-started:
- case <-time.After(5 * time.Second):
- t.Fatal("first runner did not claim the job")
- }
- stopFirst()
- require.NoError(t, <-firstDone)
- _, err = store.Pool().Exec(t.Context(), "UPDATE jobs SET lease_until = now() - interval '1 second' WHERE id = $1", job.ID)
- require.NoError(t, err)
-
- second, err := jobs.NewRunner(store, "backend-after-restart", cfg, zerolog.New(io.Discard), jobs.WithHandler(&integrationJobHandler{jobType: "test.restart"}))
- require.NoError(t, err)
- secondContext, stopSecond := context.WithCancel(t.Context())
- secondDone := make(chan error, 1)
- go func() { secondDone <- second.Run(secondContext) }()
- require.Eventually(t, func() bool {
- current, getErr := store.GetJob(t.Context(), workspaceID, job.ID)
- return getErr == nil && current.Status == "succeeded" && current.Attempt == 2
- }, 5*time.Second, 20*time.Millisecond)
- stopSecond()
- require.NoError(t, <-secondDone)
- })
-
- t.Run("repository sync completion is fenced and records the current commit", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "sync-retry")
- repositoryValue := integrationCreateRepository(t, store, workspaceID, "https://example.com/retry.git")
- payload, err := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryValue.ID})
- require.NoError(t, err)
- _ = integrationCreateJob(t, store, workspaceID, "sync-retry", "repository.sync", payload)
- first, err := store.ClaimJob(t.Context(), []string{"repository.sync"}, "backend-one", time.Now().Add(time.Minute))
- require.NoError(t, err)
- require.NoError(t, store.MarkRepositorySyncing(t.Context(), first, repositoryValue.ID))
-
- _, err = store.Pool().Exec(t.Context(), "UPDATE jobs SET lease_until = now() - interval '1 second' WHERE id = $1", first.ID)
- require.NoError(t, err)
- second, err := store.ClaimJob(t.Context(), []string{"repository.sync"}, "backend-two", time.Now().Add(time.Minute))
- require.NoError(t, err)
- require.ErrorIs(t, store.MarkSyncReady(t.Context(), first, repositoryValue.ID, "old"), repository.ErrLeaseLost)
- commitSHA := "0123456789abcdef0123456789abcdef01234567"
- require.NoError(t, store.MarkSyncReady(t.Context(), second, repositoryValue.ID, commitSHA))
-
- current, err := store.GetRepository(t.Context(), workspaceID, repositoryValue.ID)
- require.NoError(t, err)
- require.Equal(t, "ready", current.State)
- require.Equal(t, commitSHA, current.CurrentCommitSHA)
- require.NotNil(t, current.SyncedAt)
- })
-
- t.Run("job cancellation and repository soft deletion fence active work", func(t *testing.T) {
- resetPostgreSQL(t, store)
- userID, workspaceID := seedTenant(t, store, "delete")
- repositoryValue := integrationCreateRepository(t, store, workspaceID, "https://example.com/delete.git")
- payload, err := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryValue.ID})
- require.NoError(t, err)
- _ = integrationCreateJob(t, store, workspaceID, "delete-active", "repository.sync", payload)
- claimed, err := store.ClaimJob(t.Context(), []string{"repository.sync"}, "backend-delete", time.Now().Add(time.Minute))
- require.NoError(t, err)
- require.NoError(t, store.MarkRepositorySyncing(t.Context(), claimed, repositoryValue.ID))
-
- err = store.WithinMetadataTx(t.Context(), func(tx repository.MetadataStore) error {
- cancelled, txErr := tx.CancelActiveRepositorySyncJob(t.Context(), workspaceID, repositoryValue.ID)
- if txErr != nil {
- return txErr
- }
- if cancelled.Status != "cancelled" {
- return fmt.Errorf("cancelled status = %s", cancelled.Status)
- }
- if txErr = tx.CancelRepositorySyncJobs(t.Context(), workspaceID, repositoryValue.ID); txErr != nil {
- return txErr
- }
- if _, txErr = tx.SoftDeleteRepository(t.Context(), workspaceID, repositoryValue.ID); txErr != nil {
- return txErr
- }
- return tx.AppendAudit(t.Context(), workspaceID, userID, "repository.delete", "repository", repositoryValue.ID, []byte(`{}`))
- })
- require.NoError(t, err)
- require.ErrorIs(t, store.MarkRepositorySyncing(t.Context(), claimed, repositoryValue.ID), repository.ErrLeaseLost)
- _, err = store.GetRepository(t.Context(), workspaceID, repositoryValue.ID)
- require.ErrorIs(t, err, repository.ErrNotFound)
- var status string
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT status FROM jobs WHERE id = $1", claimed.ID).Scan(&status))
- require.Equal(t, "cancelled", status)
- logs, err := store.ListAuditLogs(t.Context(), workspaceID, repository.TimeCursor{}, 10)
- require.NoError(t, err)
- require.Len(t, logs, 1)
- require.Equal(t, "repository.delete", logs[0].Action)
- })
-
- t.Run("channel lease takeover fences mutable status", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "channels")
- instance := seedChannel(t, store, workspaceID)
- first, err := store.AcquireChannelLease(t.Context(), instance.ID, "backend-one", time.Now().Add(time.Minute))
- require.NoError(t, err)
- _, err = store.AcquireChannelLease(t.Context(), instance.ID, "backend-two", time.Now().Add(time.Minute))
- require.Error(t, err)
-
- _, err = store.Pool().Exec(t.Context(), "UPDATE channel_leases SET lease_until = now() - interval '1 second' WHERE channel_instance_id = $1", instance.ID)
- require.NoError(t, err)
- second, err := store.AcquireChannelLease(t.Context(), instance.ID, "backend-two", time.Now().Add(time.Minute))
- require.NoError(t, err)
- require.Greater(t, second.FencingToken, first.FencingToken)
- require.ErrorIs(t, store.RenewChannelLease(t.Context(), first, time.Now().Add(time.Minute)), repository.ErrLeaseLost)
- require.ErrorIs(t, store.SetChannelStatus(t.Context(), model.ChannelRuntimeStatus{ChannelInstanceID: instance.ID, State: "running", BackendInstanceID: first.Owner, FencingToken: first.FencingToken}), repository.ErrLeaseLost)
- require.NoError(t, store.SetChannelStatus(t.Context(), model.ChannelRuntimeStatus{ChannelInstanceID: instance.ID, State: "running", BackendInstanceID: second.Owner, FencingToken: second.FencingToken}))
- })
-
- t.Run("inbox duplicate and outbox are atomic", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "inbox")
- instance := seedChannel(t, store, workspaceID)
- message := channelcore.InboundMessage{
- ExternalEventID: "event-1", ExternalMessageID: "message-1",
- Conversation: channelcore.ConversationRef{ID: "chat-1", Type: "group"},
- Sender: channelcore.SenderInfo{PlatformID: "sender-1", CanonicalID: "feishu:sender-1", DisplayName: "Sender"},
- Content: channelcore.MessageContent{Type: "text", Text: "hello"}, OccurredAt: time.Now().UTC(),
- }
- inserted, err := store.AcceptInbound(t.Context(), instance, message)
- require.NoError(t, err)
- require.True(t, inserted)
- inserted, err = store.AcceptInbound(t.Context(), instance, message)
- require.NoError(t, err)
- require.False(t, inserted)
- for table, expected := range map[string]int{"inbox_events": 1, "im_messages": 1, "outbox_events": 1} {
- var count int
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM "+table).Scan(&count))
- require.Equal(t, expected, count, table)
- }
- })
-
- t.Run("fake channel factory reaches the persisted inbox", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "fake-channel")
- instance := seedChannel(t, store, workspaceID)
- _, err := store.SetChannelEnabled(t.Context(), workspaceID, instance.ID, true)
- require.NoError(t, err)
- registry, err := channelcore.NewRegistry(channelcore.WithFactory(&integrationChannelFactory{}))
- require.NoError(t, err)
- cfg, err := config.NewLoader().Load("", nil)
- require.NoError(t, err)
- manager := channelmanager.NewManager(store, noSecretStore{}, registry, "backend-e2e", cfg, zerolog.New(io.Discard))
- ctx, cancel := context.WithCancel(t.Context())
- done := make(chan error, 1)
- go func() { done <- manager.Run(ctx) }()
- require.Eventually(t, func() bool {
- messages, listErr := store.ListMessages(t.Context(), workspaceID, &instance.ID, nil, repository.MessageCursor{}, 10)
- return listErr == nil && len(messages) == 1 && messages[0].ExternalMessageID == "fake-message"
- }, 5*time.Second, 20*time.Millisecond)
- cancel()
- require.NoError(t, <-done)
-
- restarted := channelmanager.NewManager(store, noSecretStore{}, registry, "backend-e2e-restarted", cfg, zerolog.New(io.Discard))
- restartContext, stopRestart := context.WithCancel(t.Context())
- restartDone := make(chan error, 1)
- go func() { restartDone <- restarted.Run(restartContext) }()
- require.Eventually(t, func() bool {
- messages, listErr := store.ListMessages(t.Context(), workspaceID, &instance.ID, nil, repository.MessageCursor{}, 10)
- status, statusErr := store.GetChannelStatus(t.Context(), instance.ID)
- return listErr == nil && len(messages) == 1 && statusErr == nil && status.BackendInstanceID == "backend-e2e-restarted"
- }, 5*time.Second, 20*time.Millisecond)
- stopRestart()
- require.NoError(t, <-restartDone)
- })
-
- t.Run("cooldown and active job uniqueness", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "cooldown")
- value := integrationCreateRepository(t, store, workspaceID, "https://example.com/cooldown.git")
- require.NoError(t, store.ReserveRepositorySync(t.Context(), workspaceID, value.ID, time.Now().Add(time.Minute)))
- require.ErrorIs(t, store.ReserveRepositorySync(t.Context(), workspaceID, value.ID, time.Now().Add(time.Minute)), repository.ErrConflict)
-
- payload, err := json.Marshal(model.RepositorySyncPayload{RepositoryID: value.ID})
- require.NoError(t, err)
- _ = integrationCreateJob(t, store, workspaceID, "active-one", "repository.sync", payload)
- _, err = store.CreateJob(t.Context(), model.Job{ID: uuid.New(), WorkspaceID: workspaceID, Type: "repository.sync", Payload: payload, MaxAttempts: 5, RunAfter: time.Now()}, "active-two")
- require.ErrorIs(t, err, repository.ErrConflict)
- })
-
- t.Run("mutation gate is cross-replica safe and idempotent", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "mutations")
- now := time.Now().UTC()
- request := repository.MutationRequest{
- WorkspaceID: workspaceID, Operation: "channel.enable", ResourceKey: uuid.NewString(),
- IdempotencyKey: "request-one", RequestHash: "hash-one", ResourceID: uuid.New(),
- ActiveUntil: now.Add(time.Minute), NextAllowedAt: now.Add(time.Minute),
- }
- claim, err := store.BeginMutation(t.Context(), request)
- require.NoError(t, err)
- require.False(t, claim.Replay)
-
- contender := request
- contender.IdempotencyKey = "request-two"
- contender.RequestHash = "hash-two"
- _, err = store.BeginMutation(t.Context(), contender)
- require.ErrorIs(t, err, repository.ErrConflict)
-
- require.NoError(t, store.FinishMutation(t.Context(), request, true))
- claim, err = store.BeginMutation(t.Context(), request)
- require.NoError(t, err)
- require.True(t, claim.Replay)
- require.Equal(t, request.ResourceID, claim.ResourceID)
-
- mismatched := request
- mismatched.RequestHash = "changed-body"
- _, err = store.BeginMutation(t.Context(), mismatched)
- require.ErrorIs(t, err, repository.ErrConflict)
- })
-
- t.Run("outbox claims skip locked", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "dispatch")
- outboxID := uuid.New()
- _, err := store.Pool().Exec(t.Context(), `INSERT INTO outbox_events (id, workspace_id, aggregate, aggregate_id, event_type, payload) VALUES ($1,$2,'test',$3,'test.created','{}')`, outboxID, workspaceID, uuid.New())
- require.NoError(t, err)
- require.Equal(t, 1, concurrentlyClaimOutbox(t, store, 8))
- })
-
- t.Run("business write and audit roll back together", func(t *testing.T) {
- resetPostgreSQL(t, store)
- userID, workspaceID := seedTenant(t, store, "audit")
- repositoryID := uuid.New()
- sentinel := errors.New("force rollback")
- err := store.WithinMetadataTx(t.Context(), func(tx repository.MetadataStore) error {
- _, err := tx.CreateRepository(t.Context(), model.Repository{ID: repositoryID, WorkspaceID: workspaceID, Name: "rollback", CloneURL: "https://example.com/rollback.git", NormalizedURL: "https://example.com/rollback.git"})
- if err != nil {
- return err
- }
- if err := tx.AppendAudit(t.Context(), workspaceID, userID, "repository.create", "repository", repositoryID, []byte(`{}`)); err != nil {
- return err
- }
- return sentinel
- })
- require.ErrorIs(t, err, sentinel)
- _, err = store.GetRepository(t.Context(), workspaceID, repositoryID)
- require.ErrorIs(t, err, repository.ErrNotFound)
- var auditCount int
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM audit_logs").Scan(&auditCount))
- require.Zero(t, auditCount)
- })
-
- t.Run("soft deletion releases secret references", func(t *testing.T) {
- resetPostgreSQL(t, store)
- _, workspaceID := seedTenant(t, store, "secret-release")
- connectionSecret := seedSecretRecord(t, store, workspaceID, "scm_connection")
- connection, err := store.CreateSCMConnection(t.Context(), model.SCMConnection{ID: uuid.New(), WorkspaceID: workspaceID, Type: "generic", Name: "secret connection", BaseURL: "https://git.example.com", AuthType: "token", SecretRef: &connectionSecret})
- require.NoError(t, err)
- _, err = store.SoftDeleteSCMConnection(t.Context(), workspaceID, connection.ID)
- require.NoError(t, err)
- require.NoError(t, store.DeleteSecretRecord(t.Context(), connectionSecret, workspaceID))
-
- channelSecret := seedSecretRecord(t, store, workspaceID, "channel_instance")
- instance, err := store.CreateChannel(t.Context(), model.ChannelInstance{ID: uuid.New(), WorkspaceID: workspaceID, Type: "fake", Name: "secret channel", SecretRef: &channelSecret, Config: model.ChannelConfig{Values: map[string]any{}}})
- require.NoError(t, err)
- _, err = store.SoftDeleteChannel(t.Context(), workspaceID, instance.ID)
- require.NoError(t, err)
- require.NoError(t, store.DeleteSecretRecord(t.Context(), channelSecret, workspaceID))
-
- var count int
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM secrets").Scan(&count))
- require.Zero(t, count)
- })
-
- t.Run("failed mutation audit persists with nullable resource", func(t *testing.T) {
- resetPostgreSQL(t, store)
- userID, workspaceID := seedTenant(t, store, "failure-audit")
- identities := service.NewIdentityService(store, config.Config{})
- workspaces := service.NewWorkspaceService(store, identities)
-
- _, err := workspaces.Update(t.Context(), model.Principal{User: model.User{ID: userID, Status: "active"}}, workspaceID, "")
- require.Error(t, err)
-
- logs, err := store.ListAuditLogs(t.Context(), workspaceID, repository.TimeCursor{}, 10)
- require.NoError(t, err)
- require.Len(t, logs, 1)
- require.Equal(t, "workspace.update", logs[0].Action)
- require.Equal(t, "failure", logs[0].Result)
- require.NotNil(t, logs[0].ResourceID)
- require.Equal(t, workspaceID, *logs[0].ResourceID)
-
- require.NoError(t, store.AppendAuditResult(t.Context(), workspaceID, userID, "repository.create", "repository", uuid.Nil, "failure", []byte(`{"code":"operation_failed"}`)))
- var resourceID *uuid.UUID
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT resource_id FROM audit_logs WHERE action = 'repository.create'").Scan(&resourceID))
- require.Nil(t, resourceID)
- })
-
- t.Run("oauth registration and invitation flow is transactional", func(t *testing.T) {
- resetPostgreSQL(t, store)
- cfg, err := config.NewLoader().Load("", map[string]any{
- "registration.mode": "public",
- "identity.issuer": "https://github.com",
- })
- require.NoError(t, err)
- identities := service.NewIdentityService(store, cfg)
- registrations := service.NewRegistrationService(store, identities, cfg)
- owner, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "100", Username: "owner", Email: "owner@example.com"})
- require.NoError(t, err)
- ownerSession, err := registrations.Complete(t.Context(), owner, service.CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- require.Equal(t, "active", ownerSession.User.Status)
- require.Len(t, ownerSession.Workspaces, 1)
-
- workspaces := service.NewWorkspaceService(store, identities)
- team, err := workspaces.Create(t.Context(), model.Principal{User: ownerSession.User}, "Engineering")
- require.NoError(t, err)
- created, err := registrations.CreateInvitation(t.Context(), model.Principal{User: ownerSession.User}, team.ID, service.CreateInvitationInput{Email: "member@example.com", Role: "member"})
- require.NoError(t, err)
- require.NotEmpty(t, created.Token)
- var storedTokenHash []byte
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT token_hash FROM workspace_invitations WHERE id = $1", created.ID).Scan(&storedTokenHash))
- require.Len(t, storedTokenHash, 32)
- require.NotEqual(t, []byte(created.Token), storedTokenHash)
-
- cfg.Registration.Mode = "invite_only"
- inviteIdentities := service.NewIdentityService(store, cfg)
- inviteRegistrations := service.NewRegistrationService(store, inviteIdentities, cfg)
- member, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "200", Username: "member", Email: "member@example.com"})
- require.NoError(t, err)
- memberSession, err := inviteRegistrations.AcceptInvitation(t.Context(), member, service.AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- require.Equal(t, "active", memberSession.User.Status)
- require.Len(t, memberSession.Workspaces, 2)
-
- secondTeam, err := workspaces.Create(t.Context(), model.Principal{User: ownerSession.User}, "Platform")
- require.NoError(t, err)
- activeInvitation, err := inviteRegistrations.CreateInvitation(t.Context(), model.Principal{User: ownerSession.User}, secondTeam.ID, service.CreateInvitationInput{Email: "member@example.com", Role: "member"})
- require.NoError(t, err)
- memberSession, err = inviteRegistrations.AcceptInvitation(t.Context(), model.Principal{User: memberSession.User}, service.AcceptInvitationInput{Token: activeInvitation.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- require.Len(t, memberSession.Workspaces, 3)
-
- conflictingInvitation, err := inviteRegistrations.CreateInvitation(t.Context(), model.Principal{User: ownerSession.User}, secondTeam.ID, service.CreateInvitationInput{Email: "member@example.com", Role: "viewer"})
- require.NoError(t, err)
- _, err = inviteRegistrations.AcceptInvitation(t.Context(), model.Principal{User: memberSession.User}, service.AcceptInvitationInput{Token: conflictingInvitation.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.ErrorIs(t, err, service.ErrInvitationInvalid)
- membership, err := identities.Workspace(t.Context(), model.Principal{User: memberSession.User}, secondTeam.ID)
- require.NoError(t, err)
- require.Equal(t, "member", membership.Role)
-
- var accepted, audits int
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM workspace_invitations WHERE accepted_by = $1", memberSession.User.ID).Scan(&accepted))
- require.NoError(t, store.Pool().QueryRow(t.Context(), "SELECT count(*) FROM account_audit_logs").Scan(&audits))
- require.Equal(t, 2, accepted)
- require.Equal(t, 3, audits)
- })
-}
-
-func startPostgreSQL(t *testing.T) *Store {
- t.Helper()
- ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
- defer cancel()
- container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: testcontainers.ContainerRequest{
- Image: "postgres:17-bookworm",
- Env: map[string]string{"POSTGRES_DB": "mooncode", "POSTGRES_USER": "mooncode", "POSTGRES_PASSWORD": "mooncode"},
- ExposedPorts: []string{"5432/tcp"},
- WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2).WithStartupTimeout(90 * time.Second),
- },
- Started: true,
- })
- require.NoError(t, err)
- t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
- host, err := container.Host(ctx)
- require.NoError(t, err)
- port, err := container.MappedPort(ctx, "5432/tcp")
- require.NoError(t, err)
-
- cfg, err := config.NewLoader().Load("", map[string]any{
- "database.url": fmt.Sprintf("postgres://mooncode:mooncode@%s:%s/mooncode?sslmode=disable", host, port.Port()),
- "database.auto_migrate": true,
- "database.min_connections": 0,
- "database.connect_timeout": 30 * time.Second,
- })
- require.NoError(t, err)
- store, err := NewStore(cfg, zerolog.New(io.Discard), trace.NewNoopTracerProvider(), propagation.TraceContext{})
- require.NoError(t, err)
- t.Cleanup(store.Close)
- return store
-}
-
-func resetPostgreSQL(t *testing.T, store *Store) {
- t.Helper()
- _, err := store.Pool().Exec(t.Context(), "TRUNCATE TABLE users CASCADE")
- require.NoError(t, err)
-}
-
-func seedTenant(t *testing.T, store *Store, suffix string) (uuid.UUID, uuid.UUID) {
- t.Helper()
- userID, workspaceID := uuid.New(), uuid.New()
- identity := suffix + "-" + userID.String()
- _, err := store.Pool().Exec(t.Context(), "INSERT INTO users (id, issuer, external_subject, username, status, activated_at) VALUES ($1,'test',$2,$2,'active',now())", userID, identity)
- require.NoError(t, err)
- _, err = store.Pool().Exec(t.Context(), "INSERT INTO workspaces (id, name, slug, kind, created_by) VALUES ($1,$2,$2,'team',$3)", workspaceID, identity, userID)
- require.NoError(t, err)
- _, err = store.Pool().Exec(t.Context(), "INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ($1,$2,'owner')", workspaceID, userID)
- require.NoError(t, err)
- return userID, workspaceID
-}
-
-func integrationCreateRepository(t *testing.T, store *Store, workspaceID uuid.UUID, remote string) model.Repository {
- t.Helper()
- value, err := store.CreateRepository(t.Context(), model.Repository{ID: uuid.New(), WorkspaceID: workspaceID, Name: "repository", CloneURL: remote, NormalizedURL: remote})
- require.NoError(t, err)
- return value
-}
-
-func integrationCreateJob(t *testing.T, store *Store, workspaceID uuid.UUID, key, jobType string, payload json.RawMessage) model.Job {
- t.Helper()
- value, err := store.CreateJob(t.Context(), model.Job{ID: uuid.New(), WorkspaceID: workspaceID, Type: jobType, Payload: payload, MaxAttempts: 5, RunAfter: time.Now().Add(-time.Second)}, key)
- require.NoError(t, err)
- return value
-}
-
-func seedChannel(t *testing.T, store *Store, workspaceID uuid.UUID) model.ChannelInstance {
- t.Helper()
- value, err := store.CreateChannel(t.Context(), model.ChannelInstance{ID: uuid.New(), WorkspaceID: workspaceID, Type: "fake", Name: "fixture", Config: model.ChannelConfig{Values: map[string]any{}}})
- require.NoError(t, err)
- return value
-}
-
-func seedSecretRecord(t *testing.T, store *Store, workspaceID uuid.UUID, resourceType string) uuid.UUID {
- t.Helper()
- id := uuid.New()
- require.NoError(t, store.CreateSecretRecord(t.Context(), secretstore.Record{
- ID: id, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: resourceType, ResourceID: uuid.New()},
- Ciphertext: []byte("ciphertext"), Nonce: []byte("nonce"), WrappedKey: []byte("wrapped"), WrappedKeyNonce: []byte("wrapped-nonce"), KeyVersion: 1,
- }))
- return id
-}
-
-func concurrentlyClaimJobs(t *testing.T, store *Store, count int, until time.Time) []model.Job {
- t.Helper()
- results := make(chan model.Job, count)
- var group sync.WaitGroup
- for index := 0; index < count; index++ {
- group.Add(1)
- go func(index int) {
- defer group.Done()
- job, err := store.ClaimJob(t.Context(), []string{"test.job"}, fmt.Sprintf("owner-%d", index), until)
- if err == nil {
- results <- job
- return
- }
- require.ErrorIs(t, err, repository.ErrNotFound)
- }(index)
- }
- group.Wait()
- close(results)
- return collect(results)
-}
-
-func concurrentlyClaimOutbox(t *testing.T, store *Store, count int) int {
- t.Helper()
- return concurrentSuccesses(t, count, func(index int) error {
- _, err := store.ClaimOutboxEvent(t.Context(), fmt.Sprintf("owner-%d", index), time.Now().Add(time.Minute))
- return err
- })
-}
-
-func concurrentSuccesses(t *testing.T, count int, claim func(int) error) int {
- t.Helper()
- results := make(chan bool, count)
- var group sync.WaitGroup
- for index := 0; index < count; index++ {
- group.Add(1)
- go func(index int) {
- defer group.Done()
- err := claim(index)
- if err != nil {
- require.ErrorIs(t, err, repository.ErrNotFound)
- }
- results <- err == nil
- }(index)
- }
- group.Wait()
- close(results)
- successes := 0
- for success := range results {
- if success {
- successes++
- }
- }
- return successes
-}
-
-func collect(values <-chan model.Job) []model.Job {
- result := make([]model.Job, 0)
- for value := range values {
- result = append(result, value)
- }
- return result
-}
-
-type integrationChannelFactory struct{}
-
-func (*integrationChannelFactory) Type() string { return "fake" }
-func (*integrationChannelFactory) Descriptor() channelcore.Descriptor {
- return channelcore.Descriptor{Type: "fake", DisplayName: "Fake Channel"}
-}
-func (*integrationChannelFactory) Validate(channelcore.InstanceConfig) error { return nil }
-func (*integrationChannelFactory) New(_ channelcore.InstanceConfig, sink channelcore.InboundSink, _ ...channelcore.ChannelOption) (channelcore.Channel, error) {
- return &integrationChannelRuntime{sink: sink}, nil
-}
-
-type integrationChannelRuntime struct{ sink channelcore.InboundSink }
-
-func (*integrationChannelRuntime) Type() string { return "fake" }
-func (runtime *integrationChannelRuntime) Run(ctx context.Context) error {
- _, err := runtime.sink.Accept(ctx, channelcore.InboundMessage{
- ExternalEventID: "fake-event", ExternalMessageID: "fake-message",
- Conversation: channelcore.ConversationRef{ID: "fake-conversation", Type: "direct"},
- Sender: channelcore.SenderInfo{PlatformID: "fake-sender", CanonicalID: "fake:sender"},
- Content: channelcore.MessageContent{Type: "text", Text: "fixture"}, OccurredAt: time.Now().UTC(),
- })
- if err != nil {
- return err
- }
- <-ctx.Done()
- return nil
-}
-
-type noSecretStore struct{}
-
-func (noSecretStore) Put(context.Context, secretstore.Scope, secretstore.SecretValues) (secretstore.SecretRef, error) {
- return secretstore.SecretRef{}, errors.New("unexpected secret write")
-}
-func (noSecretStore) Get(context.Context, secretstore.SecretRef) (secretstore.SecretValues, error) {
- return nil, errors.New("unexpected secret read")
-}
-func (noSecretStore) Delete(context.Context, secretstore.SecretRef) error { return nil }
-
-var _ channelcore.Factory = (*integrationChannelFactory)(nil)
-var _ channelcore.Channel = (*integrationChannelRuntime)(nil)
-var _ secretstore.SecretStore = noSecretStore{}
-
-type integrationJobHandler struct {
- jobType string
- started chan struct{}
- waitForCancel bool
-}
-
-func (handler *integrationJobHandler) Type() string { return handler.jobType }
-func (handler *integrationJobHandler) Handle(ctx context.Context, _ model.Job) error {
- if handler.started != nil {
- close(handler.started)
- }
- if handler.waitForCancel {
- <-ctx.Done()
- return ctx.Err()
- }
- return nil
-}
-
-var _ jobs.Handler = (*integrationJobHandler)(nil)
diff --git a/internal/repository/postgres/metadata.go b/internal/repository/postgres/metadata.go
deleted file mode 100644
index 75eac89..0000000
--- a/internal/repository/postgres/metadata.go
+++ /dev/null
@@ -1,537 +0,0 @@
-package postgres
-
-import (
- "context"
- "errors"
- "time"
-
- "github.com/google/uuid"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgconn"
- "github.com/jackc/pgx/v5/pgtype"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc"
- "go.opentelemetry.io/otel/propagation"
-)
-
-type metadataTx struct {
- queries *db.Queries
- propagator propagation.TextMapPropagator
-}
-
-func (s *Store) WithinMetadataTx(ctx context.Context, fn func(repository.MetadataStore) error) error {
- tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
- if err != nil {
- return err
- }
- defer func() { _ = tx.Rollback(ctx) }()
- if err := fn(&metadataTx{queries: s.queries.WithTx(tx), propagator: s.propagator}); err != nil {
- return err
- }
- return tx.Commit(ctx)
-}
-
-func (t *metadataTx) WithinMetadataTx(_ context.Context, fn func(repository.MetadataStore) error) error {
- return fn(t)
-}
-
-func (s *Store) CreateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) {
- return createSCMConnection(ctx, s.queries, value)
-}
-func (t *metadataTx) CreateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) {
- return createSCMConnection(ctx, t.queries, value)
-}
-func createSCMConnection(ctx context.Context, q *db.Queries, value model.SCMConnection) (model.SCMConnection, error) {
- row, err := q.CreateSCMConnection(ctx, db.CreateSCMConnectionParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), Type: value.Type, Name: value.Name, BaseUrl: value.BaseURL, AuthType: value.AuthType, SecretRef: nullableUUID(value.SecretRef)})
- if err != nil {
- return model.SCMConnection{}, mapMetadataError(err)
- }
- return connectionFromDB(row), nil
-}
-
-func (s *Store) ListSCMConnections(ctx context.Context, workspaceID uuid.UUID) ([]model.SCMConnection, error) {
- return listSCMConnections(ctx, s.queries, workspaceID)
-}
-func (t *metadataTx) ListSCMConnections(ctx context.Context, workspaceID uuid.UUID) ([]model.SCMConnection, error) {
- return listSCMConnections(ctx, t.queries, workspaceID)
-}
-func listSCMConnections(ctx context.Context, q *db.Queries, workspaceID uuid.UUID) ([]model.SCMConnection, error) {
- rows, err := q.ListSCMConnections(ctx, uuidToPG(workspaceID))
- if err != nil {
- return nil, err
- }
- result := make([]model.SCMConnection, 0, len(rows))
- for _, row := range rows {
- result = append(result, connectionFromDB(row))
- }
- return result, nil
-}
-
-func (s *Store) GetSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) {
- return getSCMConnection(ctx, s.queries, workspaceID, id)
-}
-func (t *metadataTx) GetSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) {
- return getSCMConnection(ctx, t.queries, workspaceID, id)
-}
-func getSCMConnection(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.SCMConnection, error) {
- row, err := q.GetSCMConnection(ctx, db.GetSCMConnectionParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.SCMConnection{}, mapMetadataError(err)
- }
- return connectionFromDB(row), nil
-}
-
-func (s *Store) UpdateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) {
- return updateSCMConnection(ctx, s.queries, value)
-}
-func (t *metadataTx) UpdateSCMConnection(ctx context.Context, value model.SCMConnection) (model.SCMConnection, error) {
- return updateSCMConnection(ctx, t.queries, value)
-}
-func updateSCMConnection(ctx context.Context, q *db.Queries, value model.SCMConnection) (model.SCMConnection, error) {
- row, err := q.UpdateSCMConnection(ctx, db.UpdateSCMConnectionParams{
- Name: value.Name, BaseUrl: value.BaseURL, AuthType: value.AuthType, SecretRef: nullableUUID(value.SecretRef),
- ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID),
- })
- if err != nil {
- return model.SCMConnection{}, mapMetadataError(err)
- }
- return connectionFromDB(row), nil
-}
-
-func (s *Store) SoftDeleteSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) {
- return softDeleteSCMConnection(ctx, s.queries, workspaceID, id)
-}
-func (t *metadataTx) SoftDeleteSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error) {
- return softDeleteSCMConnection(ctx, t.queries, workspaceID, id)
-}
-func softDeleteSCMConnection(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.SCMConnection, error) {
- row, err := q.SoftDeleteSCMConnection(ctx, db.SoftDeleteSCMConnectionParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.SCMConnection{}, mapMetadataError(err)
- }
- return connectionFromDB(row), nil
-}
-
-func (s *Store) CreateRepository(ctx context.Context, value model.Repository) (model.Repository, error) {
- return createRepository(ctx, s.queries, value)
-}
-func (t *metadataTx) CreateRepository(ctx context.Context, value model.Repository) (model.Repository, error) {
- return createRepository(ctx, t.queries, value)
-}
-func createRepository(ctx context.Context, q *db.Queries, value model.Repository) (model.Repository, error) {
- row, err := q.CreateRepository(ctx, db.CreateRepositoryParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), ConnectionID: nullableUUID(value.ConnectionID), Name: value.Name, CloneUrl: value.CloneURL, NormalizedUrl: value.NormalizedURL, Ref: value.Ref})
- if err != nil {
- return model.Repository{}, mapMetadataError(err)
- }
- return repositoryFromDB(row), nil
-}
-
-func (s *Store) ListRepositories(ctx context.Context, workspaceID uuid.UUID) ([]model.Repository, error) {
- return listRepositories(ctx, s.queries, workspaceID)
-}
-func (t *metadataTx) ListRepositories(ctx context.Context, workspaceID uuid.UUID) ([]model.Repository, error) {
- return listRepositories(ctx, t.queries, workspaceID)
-}
-func listRepositories(ctx context.Context, q *db.Queries, workspaceID uuid.UUID) ([]model.Repository, error) {
- rows, err := q.ListRepositories(ctx, uuidToPG(workspaceID))
- if err != nil {
- return nil, err
- }
- result := make([]model.Repository, 0, len(rows))
- for _, row := range rows {
- result = append(result, repositoryFromDB(row))
- }
- return result, nil
-}
-
-func (s *Store) GetRepository(ctx context.Context, workspaceID, id uuid.UUID) (model.Repository, error) {
- return getRepository(ctx, s.queries, workspaceID, id)
-}
-func (t *metadataTx) GetRepository(ctx context.Context, workspaceID, id uuid.UUID) (model.Repository, error) {
- return getRepository(ctx, t.queries, workspaceID, id)
-}
-func getRepository(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.Repository, error) {
- row, err := q.GetRepository(ctx, db.GetRepositoryParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.Repository{}, mapMetadataError(err)
- }
- return repositoryFromDB(row), nil
-}
-
-func (s *Store) SetRepositoryRef(ctx context.Context, workspaceID, id uuid.UUID, ref string) error {
- return setRepositoryRef(ctx, s.queries, workspaceID, id, ref)
-}
-func (t *metadataTx) SetRepositoryRef(ctx context.Context, workspaceID, id uuid.UUID, ref string) error {
- return setRepositoryRef(ctx, t.queries, workspaceID, id, ref)
-}
-func setRepositoryRef(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID, ref string) error {
- rows, err := q.SetRepositoryRef(ctx, db.SetRepositoryRefParams{Ref: ref, ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- return requireAffected(rows, err)
-}
-
-func (s *Store) CancelRepositorySyncJobs(ctx context.Context, workspaceID, repositoryID uuid.UUID) error {
- return s.queries.CancelRepositorySyncJobs(ctx, db.CancelRepositorySyncJobsParams{WorkspaceID: uuidToPG(workspaceID), RepositoryID: repositoryID.String()})
-}
-func (t *metadataTx) CancelRepositorySyncJobs(ctx context.Context, workspaceID, repositoryID uuid.UUID) error {
- return t.queries.CancelRepositorySyncJobs(ctx, db.CancelRepositorySyncJobsParams{WorkspaceID: uuidToPG(workspaceID), RepositoryID: repositoryID.String()})
-}
-func (s *Store) SoftDeleteRepository(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Repository, error) {
- return softDeleteRepository(ctx, s.queries, workspaceID, repositoryID)
-}
-func (t *metadataTx) SoftDeleteRepository(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Repository, error) {
- return softDeleteRepository(ctx, t.queries, workspaceID, repositoryID)
-}
-func softDeleteRepository(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID) (model.Repository, error) {
- row, err := q.SoftDeleteRepository(ctx, db.SoftDeleteRepositoryParams{ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.Repository{}, mapMetadataError(err)
- }
- return repositoryFromDB(row), nil
-}
-func (s *Store) ReserveRepositorySync(ctx context.Context, workspaceID, repositoryID uuid.UUID, next time.Time) error {
- return reserveSync(ctx, s.queries, workspaceID, repositoryID, next)
-}
-func (t *metadataTx) ReserveRepositorySync(ctx context.Context, workspaceID, repositoryID uuid.UUID, next time.Time) error {
- return reserveSync(ctx, t.queries, workspaceID, repositoryID, next)
-}
-func reserveSync(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID, next time.Time) error {
- _, err := q.ReserveRepositorySyncCooldown(ctx, db.ReserveRepositorySyncCooldownParams{RepositoryID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID), NextAllowedAt: timeToPG(next)})
- if errors.Is(err, pgx.ErrNoRows) {
- return repository.ErrConflict
- }
- return mapMetadataError(err)
-}
-
-func (s *Store) CreateJob(ctx context.Context, value model.Job, key string) (model.Job, error) {
- return createJob(ctx, s.queries, value, key, s.propagator)
-}
-func (t *metadataTx) CreateJob(ctx context.Context, value model.Job, key string) (model.Job, error) {
- return createJob(ctx, t.queries, value, key, t.propagator)
-}
-func createJob(ctx context.Context, q *db.Queries, value model.Job, key string, propagator propagation.TextMapPropagator) (model.Job, error) {
- traceContext := value.TraceContext
- if len(traceContext) == 0 {
- traceContext = encodeTraceContext(ctx, propagator)
- }
- row, err := q.CreateJob(ctx, db.CreateJobParams{ID: uuidToPG(value.ID), WorkspaceID: uuidToPG(value.WorkspaceID), Type: value.Type, Payload: value.Payload, IdempotencyKey: key, MaxAttempts: value.MaxAttempts, RunAfter: timeToPG(value.RunAfter), TraceContext: traceContext})
- if err != nil {
- return model.Job{}, mapMetadataError(err)
- }
- return jobFromDB(row), nil
-}
-
-func (s *Store) GetJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) {
- return getJob(ctx, s.queries, workspaceID, id)
-}
-func (t *metadataTx) GetJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) {
- return getJob(ctx, t.queries, workspaceID, id)
-}
-func getJob(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.Job, error) {
- row, err := q.GetJob(ctx, db.GetJobParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.Job{}, mapMetadataError(err)
- }
- return jobFromDB(row), nil
-}
-
-func (s *Store) CancelJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) {
- return cancelJob(ctx, s.queries, workspaceID, id)
-}
-func (t *metadataTx) CancelJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error) {
- return cancelJob(ctx, t.queries, workspaceID, id)
-}
-func cancelJob(ctx context.Context, q *db.Queries, workspaceID, id uuid.UUID) (model.Job, error) {
- row, err := q.CancelJob(ctx, db.CancelJobParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if err != nil {
- return model.Job{}, mapMetadataError(err)
- }
- return jobFromDB(row), nil
-}
-
-func (s *Store) CancelActiveRepositorySyncJob(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Job, error) {
- return cancelActiveRepositorySyncJob(ctx, s.queries, workspaceID, repositoryID)
-}
-func (t *metadataTx) CancelActiveRepositorySyncJob(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Job, error) {
- return cancelActiveRepositorySyncJob(ctx, t.queries, workspaceID, repositoryID)
-}
-func cancelActiveRepositorySyncJob(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID) (model.Job, error) {
- row, err := q.CancelActiveRepositorySyncJob(ctx, db.CancelActiveRepositorySyncJobParams{WorkspaceID: uuidToPG(workspaceID), RepositoryID: repositoryID.String()})
- if err != nil {
- return model.Job{}, mapMetadataError(err)
- }
- return jobFromDB(row), nil
-}
-
-func (s *Store) ListJobQueueDepths(ctx context.Context, types []string) ([]model.JobQueueDepth, error) {
- return listJobQueueDepths(ctx, s.queries, types)
-}
-func (t *metadataTx) ListJobQueueDepths(ctx context.Context, types []string) ([]model.JobQueueDepth, error) {
- return listJobQueueDepths(ctx, t.queries, types)
-}
-func listJobQueueDepths(ctx context.Context, q *db.Queries, types []string) ([]model.JobQueueDepth, error) {
- rows, err := q.ListJobQueueDepths(ctx, types)
- if err != nil {
- return nil, err
- }
- depths := make([]model.JobQueueDepth, 0, len(rows))
- for _, row := range rows {
- depths = append(depths, model.JobQueueDepth{Type: row.Type, Status: row.Status, Depth: row.Depth})
- }
- return depths, nil
-}
-
-func (s *Store) ClaimJob(ctx context.Context, types []string, owner string, until time.Time) (model.Job, error) {
- return claimJob(ctx, s.queries, types, owner, until)
-}
-func (t *metadataTx) ClaimJob(ctx context.Context, types []string, owner string, until time.Time) (model.Job, error) {
- return claimJob(ctx, t.queries, types, owner, until)
-}
-func claimJob(ctx context.Context, q *db.Queries, types []string, owner string, until time.Time) (model.Job, error) {
- row, err := q.ClaimJob(ctx, db.ClaimJobParams{Types: types, LeaseOwner: &owner, LeaseUntil: timeToPG(until)})
- if err != nil {
- return model.Job{}, mapMetadataError(err)
- }
- return jobFromDB(row), nil
-}
-
-func (s *Store) RenewJobLease(ctx context.Context, job model.Job, until time.Time) error {
- return renewJob(ctx, s.queries, job, until)
-}
-func (t *metadataTx) RenewJobLease(ctx context.Context, job model.Job, until time.Time) error {
- return renewJob(ctx, t.queries, job, until)
-}
-func renewJob(ctx context.Context, q *db.Queries, job model.Job, until time.Time) error {
- rows, err := q.RenewJobLease(ctx, db.RenewJobLeaseParams{LeaseUntil: timeToPG(until), ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken})
- return requireAffected(rows, err)
-}
-
-func (s *Store) CompleteJob(ctx context.Context, job model.Job) error {
- return completeJob(ctx, s.queries, job)
-}
-func (t *metadataTx) CompleteJob(ctx context.Context, job model.Job) error {
- return completeJob(ctx, t.queries, job)
-}
-func completeJob(ctx context.Context, q *db.Queries, job model.Job) error {
- rows, err := q.CompleteJob(ctx, db.CompleteJobParams{ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken})
- return requireAffected(rows, err)
-}
-
-func (s *Store) RetryJob(ctx context.Context, job model.Job, runAfter time.Time, code, message string) error {
- return retryJob(ctx, s.queries, job, runAfter, code, message)
-}
-func (t *metadataTx) RetryJob(ctx context.Context, job model.Job, runAfter time.Time, code, message string) error {
- return retryJob(ctx, t.queries, job, runAfter, code, message)
-}
-func retryJob(ctx context.Context, q *db.Queries, job model.Job, runAfter time.Time, code, message string) error {
- rows, err := q.RetryJob(ctx, db.RetryJobParams{RunAfter: timeToPG(runAfter), ErrorCode: code, ErrorMessage: message, ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken})
- return requireAffected(rows, err)
-}
-
-func (s *Store) FailJob(ctx context.Context, job model.Job, code, message string) error {
- return failJob(ctx, s.queries, job, code, message)
-}
-func (t *metadataTx) FailJob(ctx context.Context, job model.Job, code, message string) error {
- return failJob(ctx, t.queries, job, code, message)
-}
-func failJob(ctx context.Context, q *db.Queries, job model.Job, code, message string) error {
- rows, err := q.FailJob(ctx, db.FailJobParams{ErrorCode: code, ErrorMessage: message, ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken})
- return requireAffected(rows, err)
-}
-
-func (s *Store) MarkRepositorySyncing(ctx context.Context, job model.Job, repositoryID uuid.UUID) error {
- return s.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- return tx.MarkRepositorySyncing(ctx, job, repositoryID)
- })
-}
-func (t *metadataTx) MarkRepositorySyncing(ctx context.Context, job model.Job, repositoryID uuid.UUID) error {
- if err := lockCurrentJob(ctx, t.queries, job); err != nil {
- return err
- }
- return markRepoSyncing(ctx, t.queries, job.WorkspaceID, repositoryID)
-}
-func markRepoSyncing(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID) error {
- rows, err := q.MarkRepositorySyncing(ctx, db.MarkRepositorySyncingParams{ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)})
- return requireAffected(rows, err)
-}
-
-func (s *Store) MarkRepositoryFailed(ctx context.Context, workspaceID, repositoryID uuid.UUID, code, message string) error {
- return markRepoFailed(ctx, s.queries, workspaceID, repositoryID, code, message)
-}
-func (t *metadataTx) MarkRepositoryFailed(ctx context.Context, workspaceID, repositoryID uuid.UUID, code, message string) error {
- return markRepoFailed(ctx, t.queries, workspaceID, repositoryID, code, message)
-}
-func markRepoFailed(ctx context.Context, q *db.Queries, workspaceID, repositoryID uuid.UUID, code, message string) error {
- rows, err := q.MarkRepositoryFailed(ctx, db.MarkRepositoryFailedParams{ErrorCode: code, ErrorMessage: message, ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)})
- return requireAffected(rows, err)
-}
-
-func (s *Store) MarkSyncReady(ctx context.Context, job model.Job, repositoryID uuid.UUID, commitSHA string) error {
- return s.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- return tx.MarkSyncReady(ctx, job, repositoryID, commitSHA)
- })
-}
-func (t *metadataTx) MarkSyncReady(ctx context.Context, job model.Job, repositoryID uuid.UUID, commitSHA string) error {
- if err := lockCurrentJob(ctx, t.queries, job); err != nil {
- return err
- }
- workspaceID := job.WorkspaceID
- rows, err := t.queries.MarkRepositoryReady(ctx, db.MarkRepositoryReadyParams{CommitSha: commitSHA, ID: uuidToPG(repositoryID), WorkspaceID: uuidToPG(workspaceID)})
- if err := requireAffected(rows, err); err != nil {
- return err
- }
- eventID, err := uuid.NewV7()
- if err != nil {
- return err
- }
- payload := []byte(`{"repositoryId":"` + repositoryID.String() + `","commitSha":"` + commitSHA + `"}`)
- return t.queries.AppendOutboxEvent(ctx, db.AppendOutboxEventParams{ID: uuidToPG(eventID), WorkspaceID: uuidToPG(workspaceID), Aggregate: "repository", AggregateID: uuidToPG(repositoryID), EventType: "repository.synced", Payload: payload, TraceContext: encodeTraceContext(ctx, t.propagator)})
-}
-
-func (s *Store) MarkSyncFailed(ctx context.Context, job model.Job, repositoryID uuid.UUID, code, message string) error {
- return s.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- return tx.MarkSyncFailed(ctx, job, repositoryID, code, message)
- })
-}
-func (t *metadataTx) MarkSyncFailed(ctx context.Context, job model.Job, repositoryID uuid.UUID, code, message string) error {
- if err := lockCurrentJob(ctx, t.queries, job); err != nil {
- return err
- }
- return markRepoFailed(ctx, t.queries, job.WorkspaceID, repositoryID, code, message)
-}
-
-func lockCurrentJob(ctx context.Context, q *db.Queries, job model.Job) error {
- _, err := q.LockCurrentJob(ctx, db.LockCurrentJobParams{
- ID: uuidToPG(job.ID), LeaseOwner: &job.LeaseOwner, FencingToken: job.FencingToken,
- })
- if errors.Is(err, pgx.ErrNoRows) {
- return repository.ErrLeaseLost
- }
- return err
-}
-
-func (s *Store) AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error {
- return appendAudit(ctx, s.queries, workspaceID, actorID, action, resourceType, resourceID, metadata)
-}
-func (t *metadataTx) AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error {
- return appendAudit(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, metadata)
-}
-func appendAudit(ctx context.Context, q *db.Queries, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error {
- return appendAuditResult(ctx, q, workspaceID, actorID, action, resourceType, resourceID, "success", metadata)
-}
-func (s *Store) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error {
- return appendAuditResult(ctx, s.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata)
-}
-func (t *metadataTx) AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error {
- return appendAuditResult(ctx, t.queries, workspaceID, actorID, action, resourceType, resourceID, result, metadata)
-}
-func appendAuditResult(ctx context.Context, q *db.Queries, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error {
- id, err := uuid.NewV7()
- if err != nil {
- return err
- }
- return q.AppendAuditLog(ctx, db.AppendAuditLogParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID), ActorUserID: uuidToPG(actorID), Action: action, ResourceType: resourceType, ResourceID: nullableUUIDToPG(resourceID), Result: result, Metadata: metadata})
-}
-
-func (s *Store) ListAuditLogs(ctx context.Context, workspaceID uuid.UUID, cursor repository.TimeCursor, limit int32) ([]model.AuditLog, error) {
- return listAuditLogs(ctx, s.queries, workspaceID, cursor, limit)
-}
-func (t *metadataTx) ListAuditLogs(ctx context.Context, workspaceID uuid.UUID, cursor repository.TimeCursor, limit int32) ([]model.AuditLog, error) {
- return listAuditLogs(ctx, t.queries, workspaceID, cursor, limit)
-}
-func listAuditLogs(ctx context.Context, q *db.Queries, workspaceID uuid.UUID, cursor repository.TimeCursor, limit int32) ([]model.AuditLog, error) {
- params := db.ListAuditLogsParams{WorkspaceID: uuidToPG(workspaceID), PageSize: limit}
- if cursor.BeforeTime != nil {
- params.BeforeTime = nullableTime(cursor.BeforeTime)
- params.BeforeID = nullableUUID(cursor.BeforeID)
- }
- rows, err := q.ListAuditLogs(ctx, params)
- if err != nil {
- return nil, err
- }
- result := make([]model.AuditLog, 0, len(rows))
- for _, row := range rows {
- result = append(result, model.AuditLog{
- ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), ActorUserID: uuidPointer(row.ActorUserID),
- Action: row.Action, ResourceType: row.ResourceType, ResourceID: uuidPointer(row.ResourceID),
- Result: row.Result, Metadata: row.Metadata, OccurredAt: row.OccurredAt.Time,
- })
- }
- return result, nil
-}
-
-func (s *Store) GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (model.WorkspaceOverview, error) {
- return getWorkspaceOverview(ctx, s.queries, workspaceID)
-}
-
-func (t *metadataTx) GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (model.WorkspaceOverview, error) {
- return getWorkspaceOverview(ctx, t.queries, workspaceID)
-}
-
-func getWorkspaceOverview(ctx context.Context, queries *db.Queries, workspaceID uuid.UUID) (model.WorkspaceOverview, error) {
- row, err := queries.GetWorkspaceOverview(ctx, uuidToPG(workspaceID))
- if err != nil {
- return model.WorkspaceOverview{}, err
- }
- return model.WorkspaceOverview{RepositoryCount: row.RepositoryCount, ActiveChannelCount: row.ActiveChannelCount, FailedJobCount: row.FailedJobCount}, nil
-}
-
-func connectionFromDB(row db.ScmConnection) model.SCMConnection {
- secret := uuidPointer(row.SecretRef)
- return model.SCMConnection{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Type: row.Type, Name: row.Name, BaseURL: row.BaseUrl, AuthType: row.AuthType, SecretRef: secret, SecretConfigured: secret != nil, CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time}
-}
-func repositoryFromDB(row db.Repository) model.Repository {
- return model.Repository{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), ConnectionID: uuidPointer(row.ConnectionID), Name: row.Name, CloneURL: row.CloneUrl, NormalizedURL: row.NormalizedUrl, Ref: row.Ref, CurrentCommitSHA: row.CurrentCommitSha, State: row.State, LastErrorCode: row.LastErrorCode, LastErrorMessage: row.LastErrorMessage, SyncedAt: timePointer(row.SyncedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time}
-}
-func jobFromDB(row db.Job) model.Job {
- leaseOwner := ""
- if row.LeaseOwner != nil {
- leaseOwner = *row.LeaseOwner
- }
- return model.Job{ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Type: row.Type, Payload: row.Payload, Status: row.Status, Attempt: row.Attempt, MaxAttempts: row.MaxAttempts, LeaseOwner: leaseOwner, LeaseUntil: timePointer(row.LeaseUntil), FencingToken: row.FencingToken, RunAfter: row.RunAfter.Time, TraceContext: row.TraceContext, LastErrorCode: row.LastErrorCode, LastErrorMessage: row.LastErrorMessage, CreatedAt: row.CreatedAt.Time, StartedAt: timePointer(row.StartedAt), FinishedAt: timePointer(row.FinishedAt), UpdatedAt: row.UpdatedAt.Time}
-}
-func nullableUUID(value *uuid.UUID) pgtype.UUID {
- if value == nil {
- return pgtype.UUID{}
- }
- return uuidToPG(*value)
-}
-func uuidPointer(value pgtype.UUID) *uuid.UUID {
- if !value.Valid {
- return nil
- }
- result := uuidFromPG(value)
- return &result
-}
-func timeToPG(value time.Time) pgtype.Timestamptz {
- return pgtype.Timestamptz{Time: value.UTC(), Valid: true}
-}
-func timePointer(value pgtype.Timestamptz) *time.Time {
- if !value.Valid {
- return nil
- }
- result := value.Time
- return &result
-}
-func requireAffected(rows int64, err error) error {
- if err != nil {
- return err
- }
- if rows == 0 {
- return repository.ErrLeaseLost
- }
- return nil
-}
-func mapMetadataError(err error) error {
- if errors.Is(err, pgx.ErrNoRows) {
- return repository.ErrNotFound
- }
- var pgErr *pgconn.PgError
- if errors.As(err, &pgErr) && pgErr.Code == "23505" {
- return repository.ErrConflict
- }
- return err
-}
-
-var _ repository.MetadataStore = (*Store)(nil)
-var _ repository.MetadataStore = (*metadataTx)(nil)
diff --git a/internal/repository/postgres/migrate.go b/internal/repository/postgres/migrate.go
deleted file mode 100644
index 45766aa..0000000
--- a/internal/repository/postgres/migrate.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package postgres
-
-import (
- "context"
- "database/sql"
- "errors"
- "fmt"
-
- "github.com/golang-migrate/migrate/v4"
- pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
- "github.com/golang-migrate/migrate/v4/source/iofs"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/stdlib"
- "github.com/mooncode-ai/mooncode/internal/config"
- moonmigrations "github.com/mooncode-ai/mooncode/migrations"
-)
-
-func MigrateUp(ctx context.Context, cfg config.DatabaseConfig) error {
- db, err := openMigrationDatabase(ctx, cfg)
- if err != nil {
- return err
- }
- defer func() { _ = db.Close() }()
-
- source, err := iofs.New(moonmigrations.PostgreSQL, "postgres")
- if err != nil {
- return fmt.Errorf("open embedded migrations: %w", err)
- }
- driver, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
- if err != nil {
- return fmt.Errorf("create migration database driver: %w", err)
- }
- runner, err := migrate.NewWithInstance("iofs", source, "mooncode", driver)
- if err != nil {
- return fmt.Errorf("create migration runner: %w", err)
- }
- if err := runner.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
- return fmt.Errorf("apply PostgreSQL migrations: %w", err)
- }
- return nil
-}
-
-func openMigrationDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sql.DB, error) {
- parsed, err := pgx.ParseConfig(cfg.URL)
- if err != nil {
- return nil, fmt.Errorf("parse database URL: %w", err)
- }
- db := stdlib.OpenDB(*parsed)
- db.SetMaxOpenConns(1)
- db.SetMaxIdleConns(1)
-
- connectCtx, cancel := context.WithTimeout(ctx, cfg.ConnectTimeout)
- defer cancel()
- if err := db.PingContext(connectCtx); err != nil {
- _ = db.Close()
- return nil, fmt.Errorf("connect to PostgreSQL for migration: %w", err)
- }
- return db, nil
-}
diff --git a/internal/repository/postgres/mutation.go b/internal/repository/postgres/mutation.go
deleted file mode 100644
index 672f722..0000000
--- a/internal/repository/postgres/mutation.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package postgres
-
-import (
- "context"
- "errors"
- "time"
-
- "github.com/google/uuid"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgtype"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-type mutationRow struct {
- idempotencyKey string
- requestHash string
- resourceID pgtype.UUID
- status string
- activeUntil pgtype.Timestamptz
- nextAllowedAt pgtype.Timestamptz
-}
-
-func (s *Store) BeginMutation(ctx context.Context, request repository.MutationRequest) (repository.MutationClaim, error) {
- tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
- if err != nil {
- return repository.MutationClaim{}, err
- }
- defer func() { _ = tx.Rollback(ctx) }()
-
- var existing mutationRow
- err = tx.QueryRow(ctx, `
-SELECT idempotency_key, request_hash, resource_id, status, active_until, next_allowed_at
-FROM mutation_controls
-WHERE workspace_id = $1 AND operation = $2 AND resource_key = $3
-FOR UPDATE`, request.WorkspaceID, request.Operation, request.ResourceKey).Scan(
- &existing.idempotencyKey, &existing.requestHash, &existing.resourceID,
- &existing.status, &existing.activeUntil, &existing.nextAllowedAt,
- )
- if err == nil {
- sameRequest := existing.idempotencyKey == request.IdempotencyKey && existing.requestHash == request.RequestHash
- if existing.idempotencyKey == request.IdempotencyKey && !sameRequest {
- return repository.MutationClaim{}, repository.ErrConflict
- }
- if sameRequest && existing.status == "succeeded" {
- if err := tx.Commit(ctx); err != nil {
- return repository.MutationClaim{}, err
- }
- return repository.MutationClaim{Replay: true, ResourceID: uuidFromPG(existing.resourceID)}, nil
- }
- now := time.Now().UTC()
- if (existing.status == "active" && existing.activeUntil.Time.After(now)) || existing.nextAllowedAt.Time.After(now) {
- return repository.MutationClaim{}, repository.ErrConflict
- }
- command, updateErr := tx.Exec(ctx, `
-UPDATE mutation_controls
-SET idempotency_key = $4, request_hash = $5, resource_id = $6,
- status = 'active', active_until = $7, next_allowed_at = $8, updated_at = now()
-WHERE workspace_id = $1 AND operation = $2 AND resource_key = $3`,
- request.WorkspaceID, request.Operation, request.ResourceKey,
- request.IdempotencyKey, request.RequestHash, nullableUUIDValue(request.ResourceID),
- request.ActiveUntil, request.NextAllowedAt,
- )
- if updateErr != nil {
- return repository.MutationClaim{}, mapMetadataError(updateErr)
- }
- if command.RowsAffected() != 1 {
- return repository.MutationClaim{}, repository.ErrConflict
- }
- } else if errors.Is(err, pgx.ErrNoRows) {
- _, err = tx.Exec(ctx, `
-INSERT INTO mutation_controls (
- workspace_id, operation, resource_key, idempotency_key, request_hash,
- resource_id, status, active_until, next_allowed_at
-) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, $8)`,
- request.WorkspaceID, request.Operation, request.ResourceKey,
- request.IdempotencyKey, request.RequestHash, nullableUUIDValue(request.ResourceID),
- request.ActiveUntil, request.NextAllowedAt,
- )
- if err != nil {
- return repository.MutationClaim{}, mapMetadataError(err)
- }
- } else {
- return repository.MutationClaim{}, err
- }
- if err := tx.Commit(ctx); err != nil {
- return repository.MutationClaim{}, err
- }
- return repository.MutationClaim{ResourceID: request.ResourceID}, nil
-}
-
-func (s *Store) FinishMutation(ctx context.Context, request repository.MutationRequest, succeeded bool) error {
- status := "failed"
- if succeeded {
- status = "succeeded"
- }
- command, err := s.pool.Exec(ctx, `
-UPDATE mutation_controls
-SET status = $6, active_until = now(), updated_at = now()
-WHERE workspace_id = $1 AND operation = $2 AND resource_key = $3
- AND idempotency_key = $4 AND request_hash = $5 AND status = 'active'`,
- request.WorkspaceID, request.Operation, request.ResourceKey,
- request.IdempotencyKey, request.RequestHash, status,
- )
- if err != nil {
- return err
- }
- if command.RowsAffected() != 1 {
- return repository.ErrConflict
- }
- return nil
-}
-
-func nullableUUIDValue(value uuid.UUID) any {
- if value == uuid.Nil {
- return nil
- }
- return value
-}
-
-var _ repository.MutationStore = (*Store)(nil)
diff --git a/internal/repository/postgres/outbox.go b/internal/repository/postgres/outbox.go
deleted file mode 100644
index f770df4..0000000
--- a/internal/repository/postgres/outbox.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package postgres
-
-import (
- "context"
- "time"
-
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc"
-)
-
-func (s *Store) ClaimOutboxEvent(ctx context.Context, owner string, leaseUntil time.Time) (model.OutboxEvent, error) {
- row, err := s.queries.ClaimOutboxEvent(ctx, db.ClaimOutboxEventParams{LeaseOwner: &owner, LeaseUntil: timeToPG(leaseUntil)})
- if err != nil {
- return model.OutboxEvent{}, mapMetadataError(err)
- }
- return outboxEventFromDB(row), nil
-}
-
-func (s *Store) CompleteOutboxEvent(ctx context.Context, event model.OutboxEvent) error {
- rows, err := s.queries.CompleteOutboxEvent(ctx, db.CompleteOutboxEventParams{
- ID: uuidToPG(event.ID), LeaseOwner: &event.LeaseOwner, Attempt: event.Attempt,
- })
- return requireAffected(rows, err)
-}
-
-func (s *Store) RetryOutboxEvent(ctx context.Context, event model.OutboxEvent, nextAttempt time.Time, code, message string) error {
- rows, err := s.queries.RetryOutboxEvent(ctx, db.RetryOutboxEventParams{
- NextAttemptAt: timeToPG(nextAttempt), ErrorCode: code, ErrorMessage: message,
- ID: uuidToPG(event.ID), LeaseOwner: &event.LeaseOwner, Attempt: event.Attempt,
- })
- return requireAffected(rows, err)
-}
-
-func (s *Store) CountOutboxBacklog(ctx context.Context) (int64, error) {
- return s.queries.CountOutboxBacklog(ctx)
-}
-
-func outboxEventFromDB(row db.OutboxEvent) model.OutboxEvent {
- owner := ""
- if row.LeaseOwner != nil {
- owner = *row.LeaseOwner
- }
- return model.OutboxEvent{
- ID: uuidFromPG(row.ID), WorkspaceID: uuidFromPG(row.WorkspaceID), Aggregate: row.Aggregate,
- AggregateID: uuidFromPG(row.AggregateID), Type: row.EventType, Payload: row.Payload, TraceContext: row.TraceContext,
- LeaseOwner: owner, LeaseUntil: timePointer(row.LeaseUntil), Attempt: row.Attempt, CreatedAt: row.CreatedAt.Time,
- }
-}
-
-var _ repository.OutboxStore = (*Store)(nil)
diff --git a/internal/repository/postgres/secret.go b/internal/repository/postgres/secret.go
deleted file mode 100644
index 6368546..0000000
--- a/internal/repository/postgres/secret.go
+++ /dev/null
@@ -1,42 +0,0 @@
-package postgres
-
-import (
- "context"
- "errors"
-
- "github.com/google/uuid"
- "github.com/jackc/pgx/v5"
- db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
-)
-
-func (s *Store) CreateSecretRecord(ctx context.Context, record secretstore.Record) error {
- _, err := s.queries.CreateSecret(ctx, db.CreateSecretParams{
- ID: uuidToPG(record.ID), WorkspaceID: uuidToPG(record.Scope.WorkspaceID),
- ResourceType: record.Scope.ResourceType, ResourceID: uuidToPG(record.Scope.ResourceID),
- Ciphertext: record.Ciphertext, Nonce: record.Nonce, WrappedKey: record.WrappedKey,
- WrappedKeyNonce: record.WrappedKeyNonce, KeyVersion: record.KeyVersion,
- })
- return err
-}
-
-func (s *Store) GetSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) (secretstore.Record, error) {
- row, err := s.queries.GetSecret(ctx, db.GetSecretParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
- if errors.Is(err, pgx.ErrNoRows) {
- return secretstore.Record{}, secretstore.ErrNotFound
- }
- if err != nil {
- return secretstore.Record{}, err
- }
- return secretstore.Record{
- ID: uuidFromPG(row.ID), Scope: secretstore.Scope{WorkspaceID: uuidFromPG(row.WorkspaceID), ResourceType: row.ResourceType, ResourceID: uuidFromPG(row.ResourceID)},
- Ciphertext: row.Ciphertext, Nonce: row.Nonce, WrappedKey: row.WrappedKey,
- WrappedKeyNonce: row.WrappedKeyNonce, KeyVersion: row.KeyVersion,
- }, nil
-}
-
-func (s *Store) DeleteSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) error {
- return s.queries.DeleteSecret(ctx, db.DeleteSecretParams{ID: uuidToPG(id), WorkspaceID: uuidToPG(workspaceID)})
-}
-
-var _ secretstore.RecordStore = (*Store)(nil)
diff --git a/internal/repository/postgres/sqlc/channels.sql.go b/internal/repository/postgres/sqlc/channels.sql.go
deleted file mode 100644
index 82fa1d7..0000000
--- a/internal/repository/postgres/sqlc/channels.sql.go
+++ /dev/null
@@ -1,705 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-// source: channels.sql
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const acquireChannelLease = `-- name: AcquireChannelLease :one
-INSERT INTO channel_leases (channel_instance_id, owner, lease_until, fencing_token)
-VALUES ($1, $2, $3, 1)
-ON CONFLICT (channel_instance_id) DO UPDATE
-SET owner = EXCLUDED.owner, lease_until = EXCLUDED.lease_until,
- fencing_token = CASE WHEN channel_leases.owner = EXCLUDED.owner
- THEN channel_leases.fencing_token ELSE channel_leases.fencing_token + 1 END,
- updated_at = now()
-WHERE channel_leases.owner = EXCLUDED.owner OR channel_leases.lease_until < now()
-RETURNING channel_instance_id, owner, lease_until, fencing_token, updated_at
-`
-
-type AcquireChannelLeaseParams struct {
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- Owner string `json:"owner"`
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
-}
-
-func (q *Queries) AcquireChannelLease(ctx context.Context, arg AcquireChannelLeaseParams) (ChannelLease, error) {
- row := q.db.QueryRow(ctx, acquireChannelLease, arg.ChannelInstanceID, arg.Owner, arg.LeaseUntil)
- var i ChannelLease
- err := row.Scan(
- &i.ChannelInstanceID,
- &i.Owner,
- &i.LeaseUntil,
- &i.FencingToken,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const createChannelInstance = `-- name: CreateChannelInstance :one
-INSERT INTO channel_instances (id, workspace_id, type, name, enabled, config, secret_ref)
-VALUES ($1, $2, $3, $4, false,
- $5, $6)
-RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at
-`
-
-type CreateChannelInstanceParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Type string `json:"type"`
- Name string `json:"name"`
- Config []byte `json:"config"`
- SecretRef pgtype.UUID `json:"secret_ref"`
-}
-
-func (q *Queries) CreateChannelInstance(ctx context.Context, arg CreateChannelInstanceParams) (ChannelInstance, error) {
- row := q.db.QueryRow(ctx, createChannelInstance,
- arg.ID,
- arg.WorkspaceID,
- arg.Type,
- arg.Name,
- arg.Config,
- arg.SecretRef,
- )
- var i ChannelInstance
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const getChannelInstance = `-- name: GetChannelInstance :one
-SELECT id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at FROM channel_instances
-WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL
-`
-
-type GetChannelInstanceParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) GetChannelInstance(ctx context.Context, arg GetChannelInstanceParams) (ChannelInstance, error) {
- row := q.db.QueryRow(ctx, getChannelInstance, arg.ID, arg.WorkspaceID)
- var i ChannelInstance
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const getChannelRuntimeStatus = `-- name: GetChannelRuntimeStatus :one
-SELECT channel_instance_id, state, backend_instance_id, fencing_token, last_connected_at, last_error_code, last_error_message, updated_at FROM channel_runtime_status WHERE channel_instance_id = $1
-`
-
-func (q *Queries) GetChannelRuntimeStatus(ctx context.Context, channelInstanceID pgtype.UUID) (ChannelRuntimeStatus, error) {
- row := q.db.QueryRow(ctx, getChannelRuntimeStatus, channelInstanceID)
- var i ChannelRuntimeStatus
- err := row.Scan(
- &i.ChannelInstanceID,
- &i.State,
- &i.BackendInstanceID,
- &i.FencingToken,
- &i.LastConnectedAt,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const insertIMMessage = `-- name: InsertIMMessage :one
-INSERT INTO im_messages (
- id, workspace_id, channel_instance_id, conversation_id, sender_id,
- external_message_id, content, occurred_at
-) VALUES (
- $1, $2, $3,
- $4, $5, $6,
- $7, $8
-)
-ON CONFLICT (channel_instance_id, external_message_id) DO NOTHING
-RETURNING id, workspace_id, channel_instance_id, conversation_id, sender_id, external_message_id, content, occurred_at, received_at, created_at
-`
-
-type InsertIMMessageParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ConversationID pgtype.UUID `json:"conversation_id"`
- SenderID pgtype.UUID `json:"sender_id"`
- ExternalMessageID string `json:"external_message_id"`
- Content []byte `json:"content"`
- OccurredAt pgtype.Timestamptz `json:"occurred_at"`
-}
-
-func (q *Queries) InsertIMMessage(ctx context.Context, arg InsertIMMessageParams) (ImMessage, error) {
- row := q.db.QueryRow(ctx, insertIMMessage,
- arg.ID,
- arg.WorkspaceID,
- arg.ChannelInstanceID,
- arg.ConversationID,
- arg.SenderID,
- arg.ExternalMessageID,
- arg.Content,
- arg.OccurredAt,
- )
- var i ImMessage
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ChannelInstanceID,
- &i.ConversationID,
- &i.SenderID,
- &i.ExternalMessageID,
- &i.Content,
- &i.OccurredAt,
- &i.ReceivedAt,
- &i.CreatedAt,
- )
- return i, err
-}
-
-const insertInboxEvent = `-- name: InsertInboxEvent :one
-INSERT INTO inbox_events (channel_instance_id, external_event_id, payload_hash)
-VALUES ($1, $2, $3)
-ON CONFLICT DO NOTHING
-RETURNING external_event_id
-`
-
-type InsertInboxEventParams struct {
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ExternalEventID string `json:"external_event_id"`
- PayloadHash string `json:"payload_hash"`
-}
-
-func (q *Queries) InsertInboxEvent(ctx context.Context, arg InsertInboxEventParams) (string, error) {
- row := q.db.QueryRow(ctx, insertInboxEvent, arg.ChannelInstanceID, arg.ExternalEventID, arg.PayloadHash)
- var external_event_id string
- err := row.Scan(&external_event_id)
- return external_event_id, err
-}
-
-const listChannelInstances = `-- name: ListChannelInstances :many
-SELECT id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at FROM channel_instances
-WHERE workspace_id = $1 AND deleted_at IS NULL
-ORDER BY created_at DESC, id DESC
-`
-
-func (q *Queries) ListChannelInstances(ctx context.Context, workspaceID pgtype.UUID) ([]ChannelInstance, error) {
- rows, err := q.db.Query(ctx, listChannelInstances, workspaceID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ChannelInstance{}
- for rows.Next() {
- var i ChannelInstance
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listEnabledChannelInstances = `-- name: ListEnabledChannelInstances :many
-SELECT id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at FROM channel_instances
-WHERE enabled = true AND deleted_at IS NULL
-ORDER BY id
-`
-
-func (q *Queries) ListEnabledChannelInstances(ctx context.Context) ([]ChannelInstance, error) {
- rows, err := q.db.Query(ctx, listEnabledChannelInstances)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ChannelInstance{}
- for rows.Next() {
- var i ChannelInstance
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listIMConversations = `-- name: ListIMConversations :many
-SELECT c.id, c.workspace_id, c.channel_instance_id, c.external_id, c.type, c.title, c.created_at, c.updated_at, ci.name AS channel_name, ci.type AS channel_type
-FROM im_conversations c
-JOIN channel_instances ci ON ci.id = c.channel_instance_id AND ci.workspace_id = c.workspace_id
-WHERE c.workspace_id = $1
-ORDER BY c.updated_at DESC, c.id DESC
-LIMIT $2
-`
-
-type ListIMConversationsParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- PageSize int32 `json:"page_size"`
-}
-
-type ListIMConversationsRow struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ExternalID string `json:"external_id"`
- Type string `json:"type"`
- Title string `json:"title"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- ChannelName string `json:"channel_name"`
- ChannelType string `json:"channel_type"`
-}
-
-func (q *Queries) ListIMConversations(ctx context.Context, arg ListIMConversationsParams) ([]ListIMConversationsRow, error) {
- rows, err := q.db.Query(ctx, listIMConversations, arg.WorkspaceID, arg.PageSize)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListIMConversationsRow{}
- for rows.Next() {
- var i ListIMConversationsRow
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ChannelInstanceID,
- &i.ExternalID,
- &i.Type,
- &i.Title,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.ChannelName,
- &i.ChannelType,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listIMMessages = `-- name: ListIMMessages :many
-SELECT m.id, m.workspace_id, m.channel_instance_id, m.conversation_id, m.sender_id, m.external_message_id, m.content, m.occurred_at, m.received_at, m.created_at, c.external_id AS conversation_external_id, c.type AS conversation_type,
- s.canonical_id AS sender_canonical_id, s.display_name AS sender_display_name,
- ci.name AS channel_name, ci.type AS channel_type
-FROM im_messages m
-JOIN im_conversations c ON c.id = m.conversation_id AND c.workspace_id = m.workspace_id
-JOIN im_senders s ON s.id = m.sender_id AND s.workspace_id = m.workspace_id
-JOIN channel_instances ci ON ci.id = m.channel_instance_id AND ci.workspace_id = m.workspace_id
-WHERE m.workspace_id = $1
- AND ($2::uuid IS NULL OR m.channel_instance_id = $2)
- AND ($3::uuid IS NULL OR m.conversation_id = $3)
- AND ($4::timestamptz IS NULL OR (m.occurred_at, m.id) < ($4, $5::uuid))
-ORDER BY m.occurred_at DESC, m.id DESC
-LIMIT $6
-`
-
-type ListIMMessagesParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ConversationID pgtype.UUID `json:"conversation_id"`
- BeforeTime pgtype.Timestamptz `json:"before_time"`
- BeforeID pgtype.UUID `json:"before_id"`
- PageSize int32 `json:"page_size"`
-}
-
-type ListIMMessagesRow struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ConversationID pgtype.UUID `json:"conversation_id"`
- SenderID pgtype.UUID `json:"sender_id"`
- ExternalMessageID string `json:"external_message_id"`
- Content []byte `json:"content"`
- OccurredAt pgtype.Timestamptz `json:"occurred_at"`
- ReceivedAt pgtype.Timestamptz `json:"received_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- ConversationExternalID string `json:"conversation_external_id"`
- ConversationType string `json:"conversation_type"`
- SenderCanonicalID string `json:"sender_canonical_id"`
- SenderDisplayName string `json:"sender_display_name"`
- ChannelName string `json:"channel_name"`
- ChannelType string `json:"channel_type"`
-}
-
-func (q *Queries) ListIMMessages(ctx context.Context, arg ListIMMessagesParams) ([]ListIMMessagesRow, error) {
- rows, err := q.db.Query(ctx, listIMMessages,
- arg.WorkspaceID,
- arg.ChannelInstanceID,
- arg.ConversationID,
- arg.BeforeTime,
- arg.BeforeID,
- arg.PageSize,
- )
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListIMMessagesRow{}
- for rows.Next() {
- var i ListIMMessagesRow
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ChannelInstanceID,
- &i.ConversationID,
- &i.SenderID,
- &i.ExternalMessageID,
- &i.Content,
- &i.OccurredAt,
- &i.ReceivedAt,
- &i.CreatedAt,
- &i.ConversationExternalID,
- &i.ConversationType,
- &i.SenderCanonicalID,
- &i.SenderDisplayName,
- &i.ChannelName,
- &i.ChannelType,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const releaseChannelLease = `-- name: ReleaseChannelLease :exec
-DELETE FROM channel_leases
-WHERE channel_instance_id = $1 AND owner = $2
- AND fencing_token = $3
-`
-
-type ReleaseChannelLeaseParams struct {
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- Owner string `json:"owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) ReleaseChannelLease(ctx context.Context, arg ReleaseChannelLeaseParams) error {
- _, err := q.db.Exec(ctx, releaseChannelLease, arg.ChannelInstanceID, arg.Owner, arg.FencingToken)
- return err
-}
-
-const renewChannelLease = `-- name: RenewChannelLease :execrows
-UPDATE channel_leases SET lease_until = $1, updated_at = now()
-WHERE channel_instance_id = $2 AND owner = $3
- AND fencing_token = $4
-`
-
-type RenewChannelLeaseParams struct {
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- Owner string `json:"owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) RenewChannelLease(ctx context.Context, arg RenewChannelLeaseParams) (int64, error) {
- result, err := q.db.Exec(ctx, renewChannelLease,
- arg.LeaseUntil,
- arg.ChannelInstanceID,
- arg.Owner,
- arg.FencingToken,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const setChannelEnabled = `-- name: SetChannelEnabled :one
-UPDATE channel_instances SET enabled = $1, config_version = config_version + 1,
- updated_at = now()
-WHERE id = $2 AND workspace_id = $3 AND deleted_at IS NULL
-RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at
-`
-
-type SetChannelEnabledParams struct {
- Enabled bool `json:"enabled"`
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (ChannelInstance, error) {
- row := q.db.QueryRow(ctx, setChannelEnabled, arg.Enabled, arg.ID, arg.WorkspaceID)
- var i ChannelInstance
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const setChannelRuntimeStatus = `-- name: SetChannelRuntimeStatus :execrows
-INSERT INTO channel_runtime_status (
- channel_instance_id, state, backend_instance_id, fencing_token,
- last_connected_at, last_error_code, last_error_message
-) SELECT
- lease.channel_instance_id, $1, $2,
- $3, $4, $5,
- $6
-FROM channel_leases AS lease
-WHERE lease.channel_instance_id = $7
- AND lease.owner = $2
- AND lease.fencing_token = $3
- AND lease.lease_until > now()
-ON CONFLICT (channel_instance_id) DO UPDATE SET
- state = EXCLUDED.state, backend_instance_id = EXCLUDED.backend_instance_id,
- fencing_token = EXCLUDED.fencing_token,
- last_connected_at = COALESCE(EXCLUDED.last_connected_at, channel_runtime_status.last_connected_at),
- last_error_code = EXCLUDED.last_error_code,
- last_error_message = EXCLUDED.last_error_message, updated_at = now()
-WHERE channel_runtime_status.fencing_token <= EXCLUDED.fencing_token
-`
-
-type SetChannelRuntimeStatusParams struct {
- State string `json:"state"`
- BackendInstanceID string `json:"backend_instance_id"`
- FencingToken int64 `json:"fencing_token"`
- LastConnectedAt pgtype.Timestamptz `json:"last_connected_at"`
- LastErrorCode string `json:"last_error_code"`
- LastErrorMessage string `json:"last_error_message"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
-}
-
-func (q *Queries) SetChannelRuntimeStatus(ctx context.Context, arg SetChannelRuntimeStatusParams) (int64, error) {
- result, err := q.db.Exec(ctx, setChannelRuntimeStatus,
- arg.State,
- arg.BackendInstanceID,
- arg.FencingToken,
- arg.LastConnectedAt,
- arg.LastErrorCode,
- arg.LastErrorMessage,
- arg.ChannelInstanceID,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const softDeleteChannel = `-- name: SoftDeleteChannel :one
-UPDATE channel_instances
-SET enabled = false, secret_ref = NULL, deleted_at = now(), config_version = config_version + 1, updated_at = now()
-WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL
-RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at
-`
-
-type SoftDeleteChannelParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) SoftDeleteChannel(ctx context.Context, arg SoftDeleteChannelParams) (ChannelInstance, error) {
- row := q.db.QueryRow(ctx, softDeleteChannel, arg.ID, arg.WorkspaceID)
- var i ChannelInstance
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const updateChannelInstance = `-- name: UpdateChannelInstance :one
-UPDATE channel_instances SET name = $1, config = $2,
- secret_ref = $3, config_version = config_version + 1, updated_at = now()
-WHERE id = $4 AND workspace_id = $5
- AND config_version = $6 AND deleted_at IS NULL
-RETURNING id, workspace_id, type, name, enabled, config, secret_ref, config_version, created_at, updated_at, deleted_at
-`
-
-type UpdateChannelInstanceParams struct {
- Name string `json:"name"`
- Config []byte `json:"config"`
- SecretRef pgtype.UUID `json:"secret_ref"`
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ExpectedVersion int64 `json:"expected_version"`
-}
-
-func (q *Queries) UpdateChannelInstance(ctx context.Context, arg UpdateChannelInstanceParams) (ChannelInstance, error) {
- row := q.db.QueryRow(ctx, updateChannelInstance,
- arg.Name,
- arg.Config,
- arg.SecretRef,
- arg.ID,
- arg.WorkspaceID,
- arg.ExpectedVersion,
- )
- var i ChannelInstance
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.Enabled,
- &i.Config,
- &i.SecretRef,
- &i.ConfigVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const upsertIMConversation = `-- name: UpsertIMConversation :one
-INSERT INTO im_conversations (id, workspace_id, channel_instance_id, external_id, type, title)
-VALUES ($1, $2, $3,
- $4, $5, $6)
-ON CONFLICT (channel_instance_id, external_id) DO UPDATE
-SET type = EXCLUDED.type, title = CASE WHEN EXCLUDED.title = '' THEN im_conversations.title ELSE EXCLUDED.title END,
- updated_at = now()
-RETURNING id, workspace_id, channel_instance_id, external_id, type, title, created_at, updated_at
-`
-
-type UpsertIMConversationParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ExternalID string `json:"external_id"`
- Type string `json:"type"`
- Title string `json:"title"`
-}
-
-func (q *Queries) UpsertIMConversation(ctx context.Context, arg UpsertIMConversationParams) (ImConversation, error) {
- row := q.db.QueryRow(ctx, upsertIMConversation,
- arg.ID,
- arg.WorkspaceID,
- arg.ChannelInstanceID,
- arg.ExternalID,
- arg.Type,
- arg.Title,
- )
- var i ImConversation
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ChannelInstanceID,
- &i.ExternalID,
- &i.Type,
- &i.Title,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const upsertIMSender = `-- name: UpsertIMSender :one
-INSERT INTO im_senders (id, workspace_id, channel_type, canonical_id, display_name)
-VALUES ($1, $2, $3,
- $4, $5)
-ON CONFLICT (workspace_id, canonical_id) DO UPDATE
-SET display_name = CASE WHEN EXCLUDED.display_name = '' THEN im_senders.display_name ELSE EXCLUDED.display_name END,
- updated_at = now()
-RETURNING id, workspace_id, channel_type, canonical_id, display_name, created_at, updated_at
-`
-
-type UpsertIMSenderParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelType string `json:"channel_type"`
- CanonicalID string `json:"canonical_id"`
- DisplayName string `json:"display_name"`
-}
-
-func (q *Queries) UpsertIMSender(ctx context.Context, arg UpsertIMSenderParams) (ImSender, error) {
- row := q.db.QueryRow(ctx, upsertIMSender,
- arg.ID,
- arg.WorkspaceID,
- arg.ChannelType,
- arg.CanonicalID,
- arg.DisplayName,
- )
- var i ImSender
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ChannelType,
- &i.CanonicalID,
- &i.DisplayName,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
diff --git a/internal/repository/postgres/sqlc/identity.sql.go b/internal/repository/postgres/sqlc/identity.sql.go
deleted file mode 100644
index 210398c..0000000
--- a/internal/repository/postgres/sqlc/identity.sql.go
+++ /dev/null
@@ -1,648 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-// source: identity.sql
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const acceptWorkspaceInvitation = `-- name: AcceptWorkspaceInvitation :execrows
-UPDATE workspace_invitations SET
- accepted_at = now(),
- accepted_by = $1,
- updated_at = now()
-WHERE id = $2 AND accepted_at IS NULL AND revoked_at IS NULL
-`
-
-type AcceptWorkspaceInvitationParams struct {
- AcceptedBy pgtype.UUID `json:"accepted_by"`
- ID pgtype.UUID `json:"id"`
-}
-
-func (q *Queries) AcceptWorkspaceInvitation(ctx context.Context, arg AcceptWorkspaceInvitationParams) (int64, error) {
- result, err := q.db.Exec(ctx, acceptWorkspaceInvitation, arg.AcceptedBy, arg.ID)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const activateUser = `-- name: ActivateUser :one
-UPDATE users SET
- status = 'active',
- terms_version = $1,
- privacy_version = $2,
- activated_at = COALESCE(activated_at, now()),
- agreements_accepted_at = now(),
- updated_at = now()
-WHERE id = $3 AND status IN ('pending', 'active')
-RETURNING id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at
-`
-
-type ActivateUserParams struct {
- TermsVersion string `json:"terms_version"`
- PrivacyVersion string `json:"privacy_version"`
- ID pgtype.UUID `json:"id"`
-}
-
-func (q *Queries) ActivateUser(ctx context.Context, arg ActivateUserParams) (User, error) {
- row := q.db.QueryRow(ctx, activateUser, arg.TermsVersion, arg.PrivacyVersion, arg.ID)
- var i User
- err := row.Scan(
- &i.ID,
- &i.Issuer,
- &i.ExternalSubject,
- &i.Username,
- &i.Email,
- &i.DisplayName,
- &i.Status,
- &i.ActivatedAt,
- &i.SuspendedAt,
- &i.LastSeenAt,
- &i.TermsVersion,
- &i.PrivacyVersion,
- &i.AgreementsAcceptedAt,
- &i.DeletedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const addWorkspaceMember = `-- name: AddWorkspaceMember :one
-INSERT INTO workspace_members (workspace_id, user_id, role)
-VALUES ($1, $2, $3)
-ON CONFLICT (workspace_id, user_id) DO NOTHING
-RETURNING workspace_id, user_id, role, created_at, updated_at
-`
-
-type AddWorkspaceMemberParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- UserID pgtype.UUID `json:"user_id"`
- Role string `json:"role"`
-}
-
-func (q *Queries) AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) (WorkspaceMember, error) {
- row := q.db.QueryRow(ctx, addWorkspaceMember, arg.WorkspaceID, arg.UserID, arg.Role)
- var i WorkspaceMember
- err := row.Scan(
- &i.WorkspaceID,
- &i.UserID,
- &i.Role,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const appendAccountAudit = `-- name: AppendAccountAudit :exec
-INSERT INTO account_audit_logs (
- id, user_id, actor_user_id, action, result, provider, request_id, metadata, occurred_at
-) VALUES (
- $1, $2, $3, $4,
- $5, $6, $7, $8, $9
-)
-`
-
-type AppendAccountAuditParams struct {
- ID pgtype.UUID `json:"id"`
- UserID pgtype.UUID `json:"user_id"`
- ActorUserID pgtype.UUID `json:"actor_user_id"`
- Action string `json:"action"`
- Result string `json:"result"`
- Provider string `json:"provider"`
- RequestID string `json:"request_id"`
- Metadata []byte `json:"metadata"`
- OccurredAt pgtype.Timestamptz `json:"occurred_at"`
-}
-
-func (q *Queries) AppendAccountAudit(ctx context.Context, arg AppendAccountAuditParams) error {
- _, err := q.db.Exec(ctx, appendAccountAudit,
- arg.ID,
- arg.UserID,
- arg.ActorUserID,
- arg.Action,
- arg.Result,
- arg.Provider,
- arg.RequestID,
- arg.Metadata,
- arg.OccurredAt,
- )
- return err
-}
-
-const createWorkspace = `-- name: CreateWorkspace :one
-INSERT INTO workspaces (id, name, slug, kind, created_by)
-VALUES ($1, $2, $3, $4, $5)
-RETURNING id, name, slug, kind, created_by, created_at, updated_at, deleted_at
-`
-
-type CreateWorkspaceParams struct {
- ID pgtype.UUID `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Kind string `json:"kind"`
- CreatedBy pgtype.UUID `json:"created_by"`
-}
-
-func (q *Queries) CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error) {
- row := q.db.QueryRow(ctx, createWorkspace,
- arg.ID,
- arg.Name,
- arg.Slug,
- arg.Kind,
- arg.CreatedBy,
- )
- var i Workspace
- err := row.Scan(
- &i.ID,
- &i.Name,
- &i.Slug,
- &i.Kind,
- &i.CreatedBy,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const createWorkspaceInvitation = `-- name: CreateWorkspaceInvitation :one
-INSERT INTO workspace_invitations (
- id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at
-) VALUES (
- $1, $2, $3, $4,
- $5, $6, $7
-)
-RETURNING id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at
-`
-
-type CreateWorkspaceInvitationParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- EmailNormalized string `json:"email_normalized"`
- Role string `json:"role"`
- TokenHash []byte `json:"token_hash"`
- InvitedBy pgtype.UUID `json:"invited_by"`
- ExpiresAt pgtype.Timestamptz `json:"expires_at"`
-}
-
-func (q *Queries) CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error) {
- row := q.db.QueryRow(ctx, createWorkspaceInvitation,
- arg.ID,
- arg.WorkspaceID,
- arg.EmailNormalized,
- arg.Role,
- arg.TokenHash,
- arg.InvitedBy,
- arg.ExpiresAt,
- )
- var i WorkspaceInvitation
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.EmailNormalized,
- &i.Role,
- &i.TokenHash,
- &i.InvitedBy,
- &i.ExpiresAt,
- &i.AcceptedAt,
- &i.AcceptedBy,
- &i.RevokedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const deleteWorkspaceMember = `-- name: DeleteWorkspaceMember :execrows
-DELETE FROM workspace_members
-WHERE workspace_id = $1 AND user_id = $2
-`
-
-type DeleteWorkspaceMemberParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- UserID pgtype.UUID `json:"user_id"`
-}
-
-func (q *Queries) DeleteWorkspaceMember(ctx context.Context, arg DeleteWorkspaceMemberParams) (int64, error) {
- result, err := q.db.Exec(ctx, deleteWorkspaceMember, arg.WorkspaceID, arg.UserID)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const getPersonalWorkspaceForUser = `-- name: GetPersonalWorkspaceForUser :one
-SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, w.deleted_at
-FROM workspaces w
-JOIN workspace_members wm ON wm.workspace_id = w.id
-WHERE wm.user_id = $1
- AND w.kind = 'personal'
- AND w.deleted_at IS NULL
-ORDER BY w.created_at
-LIMIT 1
-`
-
-func (q *Queries) GetPersonalWorkspaceForUser(ctx context.Context, userID pgtype.UUID) (Workspace, error) {
- row := q.db.QueryRow(ctx, getPersonalWorkspaceForUser, userID)
- var i Workspace
- err := row.Scan(
- &i.ID,
- &i.Name,
- &i.Slug,
- &i.Kind,
- &i.CreatedBy,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const getUserByExternalIdentity = `-- name: GetUserByExternalIdentity :one
-SELECT id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at FROM users
-WHERE issuer = $1 AND external_subject = $2
-`
-
-type GetUserByExternalIdentityParams struct {
- Issuer string `json:"issuer"`
- ExternalSubject string `json:"external_subject"`
-}
-
-func (q *Queries) GetUserByExternalIdentity(ctx context.Context, arg GetUserByExternalIdentityParams) (User, error) {
- row := q.db.QueryRow(ctx, getUserByExternalIdentity, arg.Issuer, arg.ExternalSubject)
- var i User
- err := row.Scan(
- &i.ID,
- &i.Issuer,
- &i.ExternalSubject,
- &i.Username,
- &i.Email,
- &i.DisplayName,
- &i.Status,
- &i.ActivatedAt,
- &i.SuspendedAt,
- &i.LastSeenAt,
- &i.TermsVersion,
- &i.PrivacyVersion,
- &i.AgreementsAcceptedAt,
- &i.DeletedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const getUserForUpdate = `-- name: GetUserForUpdate :one
-SELECT id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at FROM users
-WHERE id = $1
-FOR UPDATE
-`
-
-func (q *Queries) GetUserForUpdate(ctx context.Context, id pgtype.UUID) (User, error) {
- row := q.db.QueryRow(ctx, getUserForUpdate, id)
- var i User
- err := row.Scan(
- &i.ID,
- &i.Issuer,
- &i.ExternalSubject,
- &i.Username,
- &i.Email,
- &i.DisplayName,
- &i.Status,
- &i.ActivatedAt,
- &i.SuspendedAt,
- &i.LastSeenAt,
- &i.TermsVersion,
- &i.PrivacyVersion,
- &i.AgreementsAcceptedAt,
- &i.DeletedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const getWorkspaceInvitationForUpdate = `-- name: GetWorkspaceInvitationForUpdate :one
-SELECT id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at FROM workspace_invitations
-WHERE token_hash = $1
-FOR UPDATE
-`
-
-func (q *Queries) GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error) {
- row := q.db.QueryRow(ctx, getWorkspaceInvitationForUpdate, tokenHash)
- var i WorkspaceInvitation
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.EmailNormalized,
- &i.Role,
- &i.TokenHash,
- &i.InvitedBy,
- &i.ExpiresAt,
- &i.AcceptedAt,
- &i.AcceptedBy,
- &i.RevokedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const getWorkspaceMembership = `-- name: GetWorkspaceMembership :one
-SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role
-FROM workspaces w
-JOIN workspace_members wm ON wm.workspace_id = w.id
-WHERE w.id = $1
- AND wm.user_id = $2
- AND w.deleted_at IS NULL
-`
-
-type GetWorkspaceMembershipParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- UserID pgtype.UUID `json:"user_id"`
-}
-
-type GetWorkspaceMembershipRow struct {
- ID pgtype.UUID `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Kind string `json:"kind"`
- CreatedBy pgtype.UUID `json:"created_by"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- Role string `json:"role"`
-}
-
-func (q *Queries) GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error) {
- row := q.db.QueryRow(ctx, getWorkspaceMembership, arg.WorkspaceID, arg.UserID)
- var i GetWorkspaceMembershipRow
- err := row.Scan(
- &i.ID,
- &i.Name,
- &i.Slug,
- &i.Kind,
- &i.CreatedBy,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.Role,
- )
- return i, err
-}
-
-const listWorkspaceInvitations = `-- name: ListWorkspaceInvitations :many
-SELECT id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at, accepted_at, accepted_by, revoked_at, created_at, updated_at FROM workspace_invitations
-WHERE workspace_id = $1
-ORDER BY created_at DESC, id DESC
-`
-
-func (q *Queries) ListWorkspaceInvitations(ctx context.Context, workspaceID pgtype.UUID) ([]WorkspaceInvitation, error) {
- rows, err := q.db.Query(ctx, listWorkspaceInvitations, workspaceID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []WorkspaceInvitation{}
- for rows.Next() {
- var i WorkspaceInvitation
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.EmailNormalized,
- &i.Role,
- &i.TokenHash,
- &i.InvitedBy,
- &i.ExpiresAt,
- &i.AcceptedAt,
- &i.AcceptedBy,
- &i.RevokedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listWorkspaceMembers = `-- name: ListWorkspaceMembers :many
-SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at, wm.updated_at,
- u.username, u.email, u.display_name
-FROM workspace_members AS wm
-JOIN users AS u ON u.id = wm.user_id
-WHERE wm.workspace_id = $1
-ORDER BY wm.created_at, wm.user_id
-`
-
-type ListWorkspaceMembersRow struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- UserID pgtype.UUID `json:"user_id"`
- Role string `json:"role"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- Username string `json:"username"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
-}
-
-func (q *Queries) ListWorkspaceMembers(ctx context.Context, workspaceID pgtype.UUID) ([]ListWorkspaceMembersRow, error) {
- rows, err := q.db.Query(ctx, listWorkspaceMembers, workspaceID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListWorkspaceMembersRow{}
- for rows.Next() {
- var i ListWorkspaceMembersRow
- if err := rows.Scan(
- &i.WorkspaceID,
- &i.UserID,
- &i.Role,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.Username,
- &i.Email,
- &i.DisplayName,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listWorkspacesForUser = `-- name: ListWorkspacesForUser :many
-SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role
-FROM workspaces w
-JOIN workspace_members wm ON wm.workspace_id = w.id
-WHERE wm.user_id = $1
- AND w.deleted_at IS NULL
-ORDER BY w.created_at, w.id
-`
-
-type ListWorkspacesForUserRow struct {
- ID pgtype.UUID `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Kind string `json:"kind"`
- CreatedBy pgtype.UUID `json:"created_by"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- Role string `json:"role"`
-}
-
-func (q *Queries) ListWorkspacesForUser(ctx context.Context, userID pgtype.UUID) ([]ListWorkspacesForUserRow, error) {
- rows, err := q.db.Query(ctx, listWorkspacesForUser, userID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListWorkspacesForUserRow{}
- for rows.Next() {
- var i ListWorkspacesForUserRow
- if err := rows.Scan(
- &i.ID,
- &i.Name,
- &i.Slug,
- &i.Kind,
- &i.CreatedBy,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.Role,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const revokeWorkspaceInvitation = `-- name: RevokeWorkspaceInvitation :execrows
-UPDATE workspace_invitations SET revoked_at = now(), updated_at = now()
-WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL
-`
-
-func (q *Queries) RevokeWorkspaceInvitation(ctx context.Context, id pgtype.UUID) (int64, error) {
- result, err := q.db.Exec(ctx, revokeWorkspaceInvitation, id)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const updateWorkspace = `-- name: UpdateWorkspace :one
-UPDATE workspaces SET name = $1, updated_at = now()
-WHERE id = $2 AND deleted_at IS NULL
-RETURNING id, name, slug, kind, created_by, created_at, updated_at, deleted_at
-`
-
-type UpdateWorkspaceParams struct {
- Name string `json:"name"`
- ID pgtype.UUID `json:"id"`
-}
-
-func (q *Queries) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) {
- row := q.db.QueryRow(ctx, updateWorkspace, arg.Name, arg.ID)
- var i Workspace
- err := row.Scan(
- &i.ID,
- &i.Name,
- &i.Slug,
- &i.Kind,
- &i.CreatedBy,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const upsertUser = `-- name: UpsertUser :one
-INSERT INTO users (
- id, issuer, external_subject, username, email, display_name, last_seen_at
-) VALUES (
- $1, $2, $3,
- $4, $5, $6, now()
-)
-ON CONFLICT (issuer, external_subject) DO UPDATE SET
- username = EXCLUDED.username,
- email = EXCLUDED.email,
- display_name = EXCLUDED.display_name,
- last_seen_at = now(),
- updated_at = now()
-RETURNING id, issuer, external_subject, username, email, display_name, status, activated_at, suspended_at, last_seen_at, terms_version, privacy_version, agreements_accepted_at, deleted_at, created_at, updated_at
-`
-
-type UpsertUserParams struct {
- ID pgtype.UUID `json:"id"`
- Issuer string `json:"issuer"`
- ExternalSubject string `json:"external_subject"`
- Username string `json:"username"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
-}
-
-func (q *Queries) UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error) {
- row := q.db.QueryRow(ctx, upsertUser,
- arg.ID,
- arg.Issuer,
- arg.ExternalSubject,
- arg.Username,
- arg.Email,
- arg.DisplayName,
- )
- var i User
- err := row.Scan(
- &i.ID,
- &i.Issuer,
- &i.ExternalSubject,
- &i.Username,
- &i.Email,
- &i.DisplayName,
- &i.Status,
- &i.ActivatedAt,
- &i.SuspendedAt,
- &i.LastSeenAt,
- &i.TermsVersion,
- &i.PrivacyVersion,
- &i.AgreementsAcceptedAt,
- &i.DeletedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const upsertWorkspaceMember = `-- name: UpsertWorkspaceMember :exec
-INSERT INTO workspace_members (workspace_id, user_id, role)
-VALUES ($1, $2, $3)
-ON CONFLICT (workspace_id, user_id) DO UPDATE
-SET role = EXCLUDED.role, updated_at = now()
-`
-
-type UpsertWorkspaceMemberParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- UserID pgtype.UUID `json:"user_id"`
- Role string `json:"role"`
-}
-
-func (q *Queries) UpsertWorkspaceMember(ctx context.Context, arg UpsertWorkspaceMemberParams) error {
- _, err := q.db.Exec(ctx, upsertWorkspaceMember, arg.WorkspaceID, arg.UserID, arg.Role)
- return err
-}
diff --git a/internal/repository/postgres/sqlc/jobs.sql.go b/internal/repository/postgres/sqlc/jobs.sql.go
deleted file mode 100644
index e10a1d6..0000000
--- a/internal/repository/postgres/sqlc/jobs.sql.go
+++ /dev/null
@@ -1,525 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-// source: jobs.sql
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const appendAuditLog = `-- name: AppendAuditLog :exec
-INSERT INTO audit_logs (
- id, workspace_id, actor_user_id, action, resource_type, resource_id, result, metadata
-) VALUES (
- $1, $2, $3, $4,
- $5, $6, $7, $8
-)
-`
-
-type AppendAuditLogParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ActorUserID pgtype.UUID `json:"actor_user_id"`
- Action string `json:"action"`
- ResourceType string `json:"resource_type"`
- ResourceID pgtype.UUID `json:"resource_id"`
- Result string `json:"result"`
- Metadata []byte `json:"metadata"`
-}
-
-func (q *Queries) AppendAuditLog(ctx context.Context, arg AppendAuditLogParams) error {
- _, err := q.db.Exec(ctx, appendAuditLog,
- arg.ID,
- arg.WorkspaceID,
- arg.ActorUserID,
- arg.Action,
- arg.ResourceType,
- arg.ResourceID,
- arg.Result,
- arg.Metadata,
- )
- return err
-}
-
-const appendOutboxEvent = `-- name: AppendOutboxEvent :exec
-INSERT INTO outbox_events (
- id, workspace_id, aggregate, aggregate_id, event_type, payload, trace_context
-) VALUES (
- $1, $2, $3, $4,
- $5, $6, $7
-)
-`
-
-type AppendOutboxEventParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Aggregate string `json:"aggregate"`
- AggregateID pgtype.UUID `json:"aggregate_id"`
- EventType string `json:"event_type"`
- Payload []byte `json:"payload"`
- TraceContext []byte `json:"trace_context"`
-}
-
-func (q *Queries) AppendOutboxEvent(ctx context.Context, arg AppendOutboxEventParams) error {
- _, err := q.db.Exec(ctx, appendOutboxEvent,
- arg.ID,
- arg.WorkspaceID,
- arg.Aggregate,
- arg.AggregateID,
- arg.EventType,
- arg.Payload,
- arg.TraceContext,
- )
- return err
-}
-
-const cancelActiveRepositorySyncJob = `-- name: CancelActiveRepositorySyncJob :one
-UPDATE jobs
-SET status = 'cancelled', lease_owner = NULL, lease_until = NULL,
- last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user',
- finished_at = now(), updated_at = now()
-WHERE workspace_id = $1 AND type = 'repository.sync'
- AND payload->>'repositoryId' = $2::text
- AND status IN ('queued', 'running')
-RETURNING id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at
-`
-
-type CancelActiveRepositorySyncJobParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- RepositoryID string `json:"repository_id"`
-}
-
-func (q *Queries) CancelActiveRepositorySyncJob(ctx context.Context, arg CancelActiveRepositorySyncJobParams) (Job, error) {
- row := q.db.QueryRow(ctx, cancelActiveRepositorySyncJob, arg.WorkspaceID, arg.RepositoryID)
- var i Job
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Payload,
- &i.Status,
- &i.IdempotencyKey,
- &i.Attempt,
- &i.MaxAttempts,
- &i.LeaseOwner,
- &i.LeaseUntil,
- &i.FencingToken,
- &i.RunAfter,
- &i.TraceContext,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.CreatedAt,
- &i.StartedAt,
- &i.FinishedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const cancelJob = `-- name: CancelJob :one
-UPDATE jobs
-SET status = 'cancelled', lease_owner = NULL, lease_until = NULL,
- last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user',
- finished_at = now(), updated_at = now()
-WHERE id = $1 AND workspace_id = $2
- AND status IN ('queued', 'running')
-RETURNING id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at
-`
-
-type CancelJobParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) CancelJob(ctx context.Context, arg CancelJobParams) (Job, error) {
- row := q.db.QueryRow(ctx, cancelJob, arg.ID, arg.WorkspaceID)
- var i Job
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Payload,
- &i.Status,
- &i.IdempotencyKey,
- &i.Attempt,
- &i.MaxAttempts,
- &i.LeaseOwner,
- &i.LeaseUntil,
- &i.FencingToken,
- &i.RunAfter,
- &i.TraceContext,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.CreatedAt,
- &i.StartedAt,
- &i.FinishedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const claimJob = `-- name: ClaimJob :one
-WITH candidate AS (
- SELECT id FROM jobs
- WHERE type = ANY($3::text[])
- AND attempt < max_attempts
- AND ((status = 'queued' AND run_after <= now())
- OR (status = 'running' AND lease_until < now()))
- ORDER BY run_after, created_at
- FOR UPDATE SKIP LOCKED
- LIMIT 1
-)
-UPDATE jobs AS job
-SET status = 'running', lease_owner = $1,
- lease_until = $2, fencing_token = fencing_token + 1,
- attempt = attempt + 1, started_at = COALESCE(started_at, now()), updated_at = now()
-FROM candidate
-WHERE job.id = candidate.id
-RETURNING job.id, job.workspace_id, job.type, job.payload, job.status, job.idempotency_key, job.attempt, job.max_attempts, job.lease_owner, job.lease_until, job.fencing_token, job.run_after, job.trace_context, job.last_error_code, job.last_error_message, job.created_at, job.started_at, job.finished_at, job.updated_at
-`
-
-type ClaimJobParams struct {
- LeaseOwner *string `json:"lease_owner"`
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
- Types []string `json:"types"`
-}
-
-func (q *Queries) ClaimJob(ctx context.Context, arg ClaimJobParams) (Job, error) {
- row := q.db.QueryRow(ctx, claimJob, arg.LeaseOwner, arg.LeaseUntil, arg.Types)
- var i Job
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Payload,
- &i.Status,
- &i.IdempotencyKey,
- &i.Attempt,
- &i.MaxAttempts,
- &i.LeaseOwner,
- &i.LeaseUntil,
- &i.FencingToken,
- &i.RunAfter,
- &i.TraceContext,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.CreatedAt,
- &i.StartedAt,
- &i.FinishedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const completeJob = `-- name: CompleteJob :execrows
-UPDATE jobs SET status = 'succeeded', lease_owner = NULL, lease_until = NULL,
- finished_at = now(), updated_at = now()
-WHERE id = $1 AND status = 'running'
- AND lease_owner = $2 AND fencing_token = $3
-`
-
-type CompleteJobParams struct {
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) CompleteJob(ctx context.Context, arg CompleteJobParams) (int64, error) {
- result, err := q.db.Exec(ctx, completeJob, arg.ID, arg.LeaseOwner, arg.FencingToken)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const createJob = `-- name: CreateJob :one
-INSERT INTO jobs (
- id, workspace_id, type, payload, status, idempotency_key,
- max_attempts, run_after, trace_context
-) VALUES (
- $1, $2, $3, $4, 'queued',
- $5, $6, $7, $8
-)
-RETURNING id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at
-`
-
-type CreateJobParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Type string `json:"type"`
- Payload []byte `json:"payload"`
- IdempotencyKey string `json:"idempotency_key"`
- MaxAttempts int32 `json:"max_attempts"`
- RunAfter pgtype.Timestamptz `json:"run_after"`
- TraceContext []byte `json:"trace_context"`
-}
-
-func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) (Job, error) {
- row := q.db.QueryRow(ctx, createJob,
- arg.ID,
- arg.WorkspaceID,
- arg.Type,
- arg.Payload,
- arg.IdempotencyKey,
- arg.MaxAttempts,
- arg.RunAfter,
- arg.TraceContext,
- )
- var i Job
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Payload,
- &i.Status,
- &i.IdempotencyKey,
- &i.Attempt,
- &i.MaxAttempts,
- &i.LeaseOwner,
- &i.LeaseUntil,
- &i.FencingToken,
- &i.RunAfter,
- &i.TraceContext,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.CreatedAt,
- &i.StartedAt,
- &i.FinishedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const failJob = `-- name: FailJob :execrows
-UPDATE jobs SET status = 'failed', lease_owner = NULL, lease_until = NULL,
- last_error_code = $1, last_error_message = $2,
- finished_at = now(), updated_at = now()
-WHERE id = $3 AND status = 'running'
- AND lease_owner = $4 AND fencing_token = $5
-`
-
-type FailJobParams struct {
- ErrorCode string `json:"error_code"`
- ErrorMessage string `json:"error_message"`
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) {
- result, err := q.db.Exec(ctx, failJob,
- arg.ErrorCode,
- arg.ErrorMessage,
- arg.ID,
- arg.LeaseOwner,
- arg.FencingToken,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const getJob = `-- name: GetJob :one
-SELECT id, workspace_id, type, payload, status, idempotency_key, attempt, max_attempts, lease_owner, lease_until, fencing_token, run_after, trace_context, last_error_code, last_error_message, created_at, started_at, finished_at, updated_at FROM jobs WHERE id = $1 AND workspace_id = $2
-`
-
-type GetJobParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) GetJob(ctx context.Context, arg GetJobParams) (Job, error) {
- row := q.db.QueryRow(ctx, getJob, arg.ID, arg.WorkspaceID)
- var i Job
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Payload,
- &i.Status,
- &i.IdempotencyKey,
- &i.Attempt,
- &i.MaxAttempts,
- &i.LeaseOwner,
- &i.LeaseUntil,
- &i.FencingToken,
- &i.RunAfter,
- &i.TraceContext,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.CreatedAt,
- &i.StartedAt,
- &i.FinishedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const listAuditLogs = `-- name: ListAuditLogs :many
-SELECT id, workspace_id, actor_user_id, action, resource_type, resource_id, result, metadata, occurred_at FROM audit_logs
-WHERE workspace_id = $1
- AND ($2::timestamptz IS NULL OR (occurred_at, id) < ($2, $3::uuid))
-ORDER BY occurred_at DESC, id DESC
-LIMIT $4
-`
-
-type ListAuditLogsParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- BeforeTime pgtype.Timestamptz `json:"before_time"`
- BeforeID pgtype.UUID `json:"before_id"`
- PageSize int32 `json:"page_size"`
-}
-
-func (q *Queries) ListAuditLogs(ctx context.Context, arg ListAuditLogsParams) ([]AuditLog, error) {
- rows, err := q.db.Query(ctx, listAuditLogs,
- arg.WorkspaceID,
- arg.BeforeTime,
- arg.BeforeID,
- arg.PageSize,
- )
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []AuditLog{}
- for rows.Next() {
- var i AuditLog
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ActorUserID,
- &i.Action,
- &i.ResourceType,
- &i.ResourceID,
- &i.Result,
- &i.Metadata,
- &i.OccurredAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listJobQueueDepths = `-- name: ListJobQueueDepths :many
-SELECT type, status, count(*)::bigint AS depth
-FROM jobs
-WHERE type = ANY($1::text[]) AND status IN ('queued', 'running')
-GROUP BY type, status
-ORDER BY type, status
-`
-
-type ListJobQueueDepthsRow struct {
- Type string `json:"type"`
- Status string `json:"status"`
- Depth int64 `json:"depth"`
-}
-
-func (q *Queries) ListJobQueueDepths(ctx context.Context, types []string) ([]ListJobQueueDepthsRow, error) {
- rows, err := q.db.Query(ctx, listJobQueueDepths, types)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ListJobQueueDepthsRow{}
- for rows.Next() {
- var i ListJobQueueDepthsRow
- if err := rows.Scan(&i.Type, &i.Status, &i.Depth); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const lockCurrentJob = `-- name: LockCurrentJob :one
-SELECT id FROM jobs
-WHERE id = $1 AND status = 'running'
- AND lease_owner = $2
- AND fencing_token = $3
- AND lease_until > now()
-FOR UPDATE
-`
-
-type LockCurrentJobParams struct {
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) LockCurrentJob(ctx context.Context, arg LockCurrentJobParams) (pgtype.UUID, error) {
- row := q.db.QueryRow(ctx, lockCurrentJob, arg.ID, arg.LeaseOwner, arg.FencingToken)
- var id pgtype.UUID
- err := row.Scan(&id)
- return id, err
-}
-
-const renewJobLease = `-- name: RenewJobLease :execrows
-UPDATE jobs SET lease_until = $1, updated_at = now()
-WHERE id = $2 AND status = 'running'
- AND lease_owner = $3 AND fencing_token = $4
-`
-
-type RenewJobLeaseParams struct {
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) RenewJobLease(ctx context.Context, arg RenewJobLeaseParams) (int64, error) {
- result, err := q.db.Exec(ctx, renewJobLease,
- arg.LeaseUntil,
- arg.ID,
- arg.LeaseOwner,
- arg.FencingToken,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const retryJob = `-- name: RetryJob :execrows
-UPDATE jobs SET status = 'queued', lease_owner = NULL, lease_until = NULL,
- run_after = $1, last_error_code = $2,
- last_error_message = $3, updated_at = now()
-WHERE id = $4 AND status = 'running' AND attempt < max_attempts
- AND lease_owner = $5 AND fencing_token = $6
-`
-
-type RetryJobParams struct {
- RunAfter pgtype.Timestamptz `json:"run_after"`
- ErrorCode string `json:"error_code"`
- ErrorMessage string `json:"error_message"`
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- FencingToken int64 `json:"fencing_token"`
-}
-
-func (q *Queries) RetryJob(ctx context.Context, arg RetryJobParams) (int64, error) {
- result, err := q.db.Exec(ctx, retryJob,
- arg.RunAfter,
- arg.ErrorCode,
- arg.ErrorMessage,
- arg.ID,
- arg.LeaseOwner,
- arg.FencingToken,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
diff --git a/internal/repository/postgres/sqlc/models.go b/internal/repository/postgres/sqlc/models.go
deleted file mode 100644
index 7d84cb9..0000000
--- a/internal/repository/postgres/sqlc/models.go
+++ /dev/null
@@ -1,265 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-
-package db
-
-import (
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-type AccountAuditLog struct {
- ID pgtype.UUID `json:"id"`
- UserID pgtype.UUID `json:"user_id"`
- ActorUserID pgtype.UUID `json:"actor_user_id"`
- Action string `json:"action"`
- Result string `json:"result"`
- Provider string `json:"provider"`
- RequestID string `json:"request_id"`
- Metadata []byte `json:"metadata"`
- OccurredAt pgtype.Timestamptz `json:"occurred_at"`
-}
-
-type AuditLog struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ActorUserID pgtype.UUID `json:"actor_user_id"`
- Action string `json:"action"`
- ResourceType string `json:"resource_type"`
- ResourceID pgtype.UUID `json:"resource_id"`
- Result string `json:"result"`
- Metadata []byte `json:"metadata"`
- OccurredAt pgtype.Timestamptz `json:"occurred_at"`
-}
-
-type ChannelInstance struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Type string `json:"type"`
- Name string `json:"name"`
- Enabled bool `json:"enabled"`
- Config []byte `json:"config"`
- SecretRef pgtype.UUID `json:"secret_ref"`
- ConfigVersion int64 `json:"config_version"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- DeletedAt pgtype.Timestamptz `json:"deleted_at"`
-}
-
-type ChannelLease struct {
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- Owner string `json:"owner"`
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
- FencingToken int64 `json:"fencing_token"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type ChannelRuntimeStatus struct {
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- State string `json:"state"`
- BackendInstanceID string `json:"backend_instance_id"`
- FencingToken int64 `json:"fencing_token"`
- LastConnectedAt pgtype.Timestamptz `json:"last_connected_at"`
- LastErrorCode string `json:"last_error_code"`
- LastErrorMessage string `json:"last_error_message"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type ImConversation struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ExternalID string `json:"external_id"`
- Type string `json:"type"`
- Title string `json:"title"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type ImMessage struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ConversationID pgtype.UUID `json:"conversation_id"`
- SenderID pgtype.UUID `json:"sender_id"`
- ExternalMessageID string `json:"external_message_id"`
- Content []byte `json:"content"`
- OccurredAt pgtype.Timestamptz `json:"occurred_at"`
- ReceivedAt pgtype.Timestamptz `json:"received_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
-}
-
-type ImSender struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ChannelType string `json:"channel_type"`
- CanonicalID string `json:"canonical_id"`
- DisplayName string `json:"display_name"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type InboxEvent struct {
- ChannelInstanceID pgtype.UUID `json:"channel_instance_id"`
- ExternalEventID string `json:"external_event_id"`
- PayloadHash string `json:"payload_hash"`
- ReceivedAt pgtype.Timestamptz `json:"received_at"`
-}
-
-type Job struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Type string `json:"type"`
- Payload []byte `json:"payload"`
- Status string `json:"status"`
- IdempotencyKey string `json:"idempotency_key"`
- Attempt int32 `json:"attempt"`
- MaxAttempts int32 `json:"max_attempts"`
- LeaseOwner *string `json:"lease_owner"`
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
- FencingToken int64 `json:"fencing_token"`
- RunAfter pgtype.Timestamptz `json:"run_after"`
- TraceContext []byte `json:"trace_context"`
- LastErrorCode string `json:"last_error_code"`
- LastErrorMessage string `json:"last_error_message"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- StartedAt pgtype.Timestamptz `json:"started_at"`
- FinishedAt pgtype.Timestamptz `json:"finished_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type MutationControl struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Operation string `json:"operation"`
- ResourceKey string `json:"resource_key"`
- IdempotencyKey string `json:"idempotency_key"`
- RequestHash string `json:"request_hash"`
- ResourceID pgtype.UUID `json:"resource_id"`
- Status string `json:"status"`
- ActiveUntil pgtype.Timestamptz `json:"active_until"`
- NextAllowedAt pgtype.Timestamptz `json:"next_allowed_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type OutboxEvent struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Aggregate string `json:"aggregate"`
- AggregateID pgtype.UUID `json:"aggregate_id"`
- EventType string `json:"event_type"`
- Payload []byte `json:"payload"`
- TraceContext []byte `json:"trace_context"`
- LeaseOwner *string `json:"lease_owner"`
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
- Attempt int32 `json:"attempt"`
- NextAttemptAt pgtype.Timestamptz `json:"next_attempt_at"`
- LastErrorCode string `json:"last_error_code"`
- LastErrorMessage string `json:"last_error_message"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- PublishedAt pgtype.Timestamptz `json:"published_at"`
-}
-
-type Repository struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ConnectionID pgtype.UUID `json:"connection_id"`
- Name string `json:"name"`
- CloneUrl string `json:"clone_url"`
- NormalizedUrl string `json:"normalized_url"`
- Ref string `json:"ref"`
- CurrentCommitSha string `json:"current_commit_sha"`
- State string `json:"state"`
- LastErrorCode string `json:"last_error_code"`
- LastErrorMessage string `json:"last_error_message"`
- SyncedAt pgtype.Timestamptz `json:"synced_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- DeletedAt pgtype.Timestamptz `json:"deleted_at"`
-}
-
-type RepositorySyncControl struct {
- RepositoryID pgtype.UUID `json:"repository_id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- NextAllowedAt pgtype.Timestamptz `json:"next_allowed_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type ScmConnection struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Type string `json:"type"`
- Name string `json:"name"`
- BaseUrl string `json:"base_url"`
- AuthType string `json:"auth_type"`
- SecretRef pgtype.UUID `json:"secret_ref"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- DeletedAt pgtype.Timestamptz `json:"deleted_at"`
-}
-
-type Secret struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ResourceType string `json:"resource_type"`
- ResourceID pgtype.UUID `json:"resource_id"`
- Ciphertext []byte `json:"ciphertext"`
- Nonce []byte `json:"nonce"`
- WrappedKey []byte `json:"wrapped_key"`
- WrappedKeyNonce []byte `json:"wrapped_key_nonce"`
- KeyVersion int32 `json:"key_version"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type User struct {
- ID pgtype.UUID `json:"id"`
- Issuer string `json:"issuer"`
- ExternalSubject string `json:"external_subject"`
- Username string `json:"username"`
- Email string `json:"email"`
- DisplayName string `json:"display_name"`
- Status string `json:"status"`
- ActivatedAt pgtype.Timestamptz `json:"activated_at"`
- SuspendedAt pgtype.Timestamptz `json:"suspended_at"`
- LastSeenAt pgtype.Timestamptz `json:"last_seen_at"`
- TermsVersion string `json:"terms_version"`
- PrivacyVersion string `json:"privacy_version"`
- AgreementsAcceptedAt pgtype.Timestamptz `json:"agreements_accepted_at"`
- DeletedAt pgtype.Timestamptz `json:"deleted_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type Workspace struct {
- ID pgtype.UUID `json:"id"`
- Name string `json:"name"`
- Slug string `json:"slug"`
- Kind string `json:"kind"`
- CreatedBy pgtype.UUID `json:"created_by"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
- DeletedAt pgtype.Timestamptz `json:"deleted_at"`
-}
-
-type WorkspaceInvitation struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- EmailNormalized string `json:"email_normalized"`
- Role string `json:"role"`
- TokenHash []byte `json:"token_hash"`
- InvitedBy pgtype.UUID `json:"invited_by"`
- ExpiresAt pgtype.Timestamptz `json:"expires_at"`
- AcceptedAt pgtype.Timestamptz `json:"accepted_at"`
- AcceptedBy pgtype.UUID `json:"accepted_by"`
- RevokedAt pgtype.Timestamptz `json:"revoked_at"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
-
-type WorkspaceMember struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- UserID pgtype.UUID `json:"user_id"`
- Role string `json:"role"`
- CreatedAt pgtype.Timestamptz `json:"created_at"`
- UpdatedAt pgtype.Timestamptz `json:"updated_at"`
-}
diff --git a/internal/repository/postgres/sqlc/outbox.sql.go b/internal/repository/postgres/sqlc/outbox.sql.go
deleted file mode 100644
index 72c132b..0000000
--- a/internal/repository/postgres/sqlc/outbox.sql.go
+++ /dev/null
@@ -1,123 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-// source: outbox.sql
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const claimOutboxEvent = `-- name: ClaimOutboxEvent :one
-WITH candidate AS (
- SELECT id
- FROM outbox_events
- WHERE published_at IS NULL AND next_attempt_at <= now()
- AND (lease_until IS NULL OR lease_until < now())
- ORDER BY next_attempt_at, created_at
- FOR UPDATE SKIP LOCKED
- LIMIT 1
-)
-UPDATE outbox_events AS event
-SET lease_owner = $1, lease_until = $2,
- attempt = attempt + 1
-FROM candidate
-WHERE event.id = candidate.id
-RETURNING event.id, event.workspace_id, event.aggregate, event.aggregate_id, event.event_type, event.payload, event.trace_context, event.lease_owner, event.lease_until, event.attempt, event.next_attempt_at, event.last_error_code, event.last_error_message, event.created_at, event.published_at
-`
-
-type ClaimOutboxEventParams struct {
- LeaseOwner *string `json:"lease_owner"`
- LeaseUntil pgtype.Timestamptz `json:"lease_until"`
-}
-
-func (q *Queries) ClaimOutboxEvent(ctx context.Context, arg ClaimOutboxEventParams) (OutboxEvent, error) {
- row := q.db.QueryRow(ctx, claimOutboxEvent, arg.LeaseOwner, arg.LeaseUntil)
- var i OutboxEvent
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Aggregate,
- &i.AggregateID,
- &i.EventType,
- &i.Payload,
- &i.TraceContext,
- &i.LeaseOwner,
- &i.LeaseUntil,
- &i.Attempt,
- &i.NextAttemptAt,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.CreatedAt,
- &i.PublishedAt,
- )
- return i, err
-}
-
-const completeOutboxEvent = `-- name: CompleteOutboxEvent :execrows
-UPDATE outbox_events
-SET published_at = now(), lease_owner = NULL, lease_until = NULL,
- last_error_code = '', last_error_message = ''
-WHERE id = $1 AND published_at IS NULL
- AND lease_owner = $2 AND attempt = $3
-`
-
-type CompleteOutboxEventParams struct {
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- Attempt int32 `json:"attempt"`
-}
-
-func (q *Queries) CompleteOutboxEvent(ctx context.Context, arg CompleteOutboxEventParams) (int64, error) {
- result, err := q.db.Exec(ctx, completeOutboxEvent, arg.ID, arg.LeaseOwner, arg.Attempt)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const countOutboxBacklog = `-- name: CountOutboxBacklog :one
-SELECT count(*) FROM outbox_events WHERE published_at IS NULL
-`
-
-func (q *Queries) CountOutboxBacklog(ctx context.Context) (int64, error) {
- row := q.db.QueryRow(ctx, countOutboxBacklog)
- var count int64
- err := row.Scan(&count)
- return count, err
-}
-
-const retryOutboxEvent = `-- name: RetryOutboxEvent :execrows
-UPDATE outbox_events
-SET lease_owner = NULL, lease_until = NULL, next_attempt_at = $1,
- last_error_code = $2, last_error_message = $3
-WHERE id = $4 AND published_at IS NULL
- AND lease_owner = $5 AND attempt = $6
-`
-
-type RetryOutboxEventParams struct {
- NextAttemptAt pgtype.Timestamptz `json:"next_attempt_at"`
- ErrorCode string `json:"error_code"`
- ErrorMessage string `json:"error_message"`
- ID pgtype.UUID `json:"id"`
- LeaseOwner *string `json:"lease_owner"`
- Attempt int32 `json:"attempt"`
-}
-
-func (q *Queries) RetryOutboxEvent(ctx context.Context, arg RetryOutboxEventParams) (int64, error) {
- result, err := q.db.Exec(ctx, retryOutboxEvent,
- arg.NextAttemptAt,
- arg.ErrorCode,
- arg.ErrorMessage,
- arg.ID,
- arg.LeaseOwner,
- arg.Attempt,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
diff --git a/internal/repository/postgres/sqlc/querier.go b/internal/repository/postgres/sqlc/querier.go
deleted file mode 100644
index c3d6af7..0000000
--- a/internal/repository/postgres/sqlc/querier.go
+++ /dev/null
@@ -1,90 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-type Querier interface {
- AcceptWorkspaceInvitation(ctx context.Context, arg AcceptWorkspaceInvitationParams) (int64, error)
- AcquireChannelLease(ctx context.Context, arg AcquireChannelLeaseParams) (ChannelLease, error)
- ActivateUser(ctx context.Context, arg ActivateUserParams) (User, error)
- AddWorkspaceMember(ctx context.Context, arg AddWorkspaceMemberParams) (WorkspaceMember, error)
- AppendAccountAudit(ctx context.Context, arg AppendAccountAuditParams) error
- AppendAuditLog(ctx context.Context, arg AppendAuditLogParams) error
- AppendOutboxEvent(ctx context.Context, arg AppendOutboxEventParams) error
- CancelActiveRepositorySyncJob(ctx context.Context, arg CancelActiveRepositorySyncJobParams) (Job, error)
- CancelJob(ctx context.Context, arg CancelJobParams) (Job, error)
- CancelRepositorySyncJobs(ctx context.Context, arg CancelRepositorySyncJobsParams) error
- ClaimJob(ctx context.Context, arg ClaimJobParams) (Job, error)
- ClaimOutboxEvent(ctx context.Context, arg ClaimOutboxEventParams) (OutboxEvent, error)
- CompleteJob(ctx context.Context, arg CompleteJobParams) (int64, error)
- CompleteOutboxEvent(ctx context.Context, arg CompleteOutboxEventParams) (int64, error)
- CountOutboxBacklog(ctx context.Context) (int64, error)
- CreateChannelInstance(ctx context.Context, arg CreateChannelInstanceParams) (ChannelInstance, error)
- CreateJob(ctx context.Context, arg CreateJobParams) (Job, error)
- CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error)
- CreateSCMConnection(ctx context.Context, arg CreateSCMConnectionParams) (ScmConnection, error)
- CreateSecret(ctx context.Context, arg CreateSecretParams) (Secret, error)
- CreateWorkspace(ctx context.Context, arg CreateWorkspaceParams) (Workspace, error)
- CreateWorkspaceInvitation(ctx context.Context, arg CreateWorkspaceInvitationParams) (WorkspaceInvitation, error)
- DeleteSecret(ctx context.Context, arg DeleteSecretParams) error
- DeleteWorkspaceMember(ctx context.Context, arg DeleteWorkspaceMemberParams) (int64, error)
- FailJob(ctx context.Context, arg FailJobParams) (int64, error)
- GetChannelInstance(ctx context.Context, arg GetChannelInstanceParams) (ChannelInstance, error)
- GetChannelRuntimeStatus(ctx context.Context, channelInstanceID pgtype.UUID) (ChannelRuntimeStatus, error)
- GetJob(ctx context.Context, arg GetJobParams) (Job, error)
- GetPersonalWorkspaceForUser(ctx context.Context, userID pgtype.UUID) (Workspace, error)
- GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error)
- GetSCMConnection(ctx context.Context, arg GetSCMConnectionParams) (ScmConnection, error)
- GetSecret(ctx context.Context, arg GetSecretParams) (Secret, error)
- GetUserByExternalIdentity(ctx context.Context, arg GetUserByExternalIdentityParams) (User, error)
- GetUserForUpdate(ctx context.Context, id pgtype.UUID) (User, error)
- GetWorkspaceInvitationForUpdate(ctx context.Context, tokenHash []byte) (WorkspaceInvitation, error)
- GetWorkspaceMembership(ctx context.Context, arg GetWorkspaceMembershipParams) (GetWorkspaceMembershipRow, error)
- GetWorkspaceOverview(ctx context.Context, targetWorkspaceID pgtype.UUID) (GetWorkspaceOverviewRow, error)
- InsertIMMessage(ctx context.Context, arg InsertIMMessageParams) (ImMessage, error)
- InsertInboxEvent(ctx context.Context, arg InsertInboxEventParams) (string, error)
- ListAuditLogs(ctx context.Context, arg ListAuditLogsParams) ([]AuditLog, error)
- ListChannelInstances(ctx context.Context, workspaceID pgtype.UUID) ([]ChannelInstance, error)
- ListEnabledChannelInstances(ctx context.Context) ([]ChannelInstance, error)
- ListIMConversations(ctx context.Context, arg ListIMConversationsParams) ([]ListIMConversationsRow, error)
- ListIMMessages(ctx context.Context, arg ListIMMessagesParams) ([]ListIMMessagesRow, error)
- ListJobQueueDepths(ctx context.Context, types []string) ([]ListJobQueueDepthsRow, error)
- ListRepositories(ctx context.Context, workspaceID pgtype.UUID) ([]Repository, error)
- ListSCMConnections(ctx context.Context, workspaceID pgtype.UUID) ([]ScmConnection, error)
- ListWorkspaceInvitations(ctx context.Context, workspaceID pgtype.UUID) ([]WorkspaceInvitation, error)
- ListWorkspaceMembers(ctx context.Context, workspaceID pgtype.UUID) ([]ListWorkspaceMembersRow, error)
- ListWorkspacesForUser(ctx context.Context, userID pgtype.UUID) ([]ListWorkspacesForUserRow, error)
- LockCurrentJob(ctx context.Context, arg LockCurrentJobParams) (pgtype.UUID, error)
- MarkRepositoryFailed(ctx context.Context, arg MarkRepositoryFailedParams) (int64, error)
- MarkRepositoryReady(ctx context.Context, arg MarkRepositoryReadyParams) (int64, error)
- MarkRepositorySyncing(ctx context.Context, arg MarkRepositorySyncingParams) (int64, error)
- ReleaseChannelLease(ctx context.Context, arg ReleaseChannelLeaseParams) error
- RenewChannelLease(ctx context.Context, arg RenewChannelLeaseParams) (int64, error)
- RenewJobLease(ctx context.Context, arg RenewJobLeaseParams) (int64, error)
- ReserveRepositorySyncCooldown(ctx context.Context, arg ReserveRepositorySyncCooldownParams) (pgtype.UUID, error)
- RetryJob(ctx context.Context, arg RetryJobParams) (int64, error)
- RetryOutboxEvent(ctx context.Context, arg RetryOutboxEventParams) (int64, error)
- RevokeWorkspaceInvitation(ctx context.Context, id pgtype.UUID) (int64, error)
- SetChannelEnabled(ctx context.Context, arg SetChannelEnabledParams) (ChannelInstance, error)
- SetChannelRuntimeStatus(ctx context.Context, arg SetChannelRuntimeStatusParams) (int64, error)
- SetRepositoryRef(ctx context.Context, arg SetRepositoryRefParams) (int64, error)
- SoftDeleteChannel(ctx context.Context, arg SoftDeleteChannelParams) (ChannelInstance, error)
- SoftDeleteRepository(ctx context.Context, arg SoftDeleteRepositoryParams) (Repository, error)
- SoftDeleteSCMConnection(ctx context.Context, arg SoftDeleteSCMConnectionParams) (ScmConnection, error)
- UpdateChannelInstance(ctx context.Context, arg UpdateChannelInstanceParams) (ChannelInstance, error)
- UpdateSCMConnection(ctx context.Context, arg UpdateSCMConnectionParams) (ScmConnection, error)
- UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error)
- UpsertIMConversation(ctx context.Context, arg UpsertIMConversationParams) (ImConversation, error)
- UpsertIMSender(ctx context.Context, arg UpsertIMSenderParams) (ImSender, error)
- UpsertUser(ctx context.Context, arg UpsertUserParams) (User, error)
- UpsertWorkspaceMember(ctx context.Context, arg UpsertWorkspaceMemberParams) error
-}
-
-var _ Querier = (*Queries)(nil)
diff --git a/internal/repository/postgres/sqlc/repositories.sql.go b/internal/repository/postgres/sqlc/repositories.sql.go
deleted file mode 100644
index afa3b5c..0000000
--- a/internal/repository/postgres/sqlc/repositories.sql.go
+++ /dev/null
@@ -1,508 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-// source: repositories.sql
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const cancelRepositorySyncJobs = `-- name: CancelRepositorySyncJobs :exec
-UPDATE jobs
-SET status = 'cancelled', lease_owner = NULL, lease_until = NULL,
- last_error_code = 'repository.deleted', last_error_message = 'Repository was deleted',
- finished_at = now(), updated_at = now()
-WHERE workspace_id = $1 AND type = 'repository.sync'
- AND payload->>'repositoryId' = $2::text
- AND status IN ('queued', 'running')
-`
-
-type CancelRepositorySyncJobsParams struct {
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- RepositoryID string `json:"repository_id"`
-}
-
-func (q *Queries) CancelRepositorySyncJobs(ctx context.Context, arg CancelRepositorySyncJobsParams) error {
- _, err := q.db.Exec(ctx, cancelRepositorySyncJobs, arg.WorkspaceID, arg.RepositoryID)
- return err
-}
-
-const createRepository = `-- name: CreateRepository :one
-INSERT INTO repositories (
- id, workspace_id, connection_id, name, clone_url, normalized_url,
- ref, state
-) VALUES (
- $1, $2, $3, $4,
- $5, $6, $7, 'pending'
-)
-RETURNING id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at
-`
-
-type CreateRepositoryParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ConnectionID pgtype.UUID `json:"connection_id"`
- Name string `json:"name"`
- CloneUrl string `json:"clone_url"`
- NormalizedUrl string `json:"normalized_url"`
- Ref string `json:"ref"`
-}
-
-func (q *Queries) CreateRepository(ctx context.Context, arg CreateRepositoryParams) (Repository, error) {
- row := q.db.QueryRow(ctx, createRepository,
- arg.ID,
- arg.WorkspaceID,
- arg.ConnectionID,
- arg.Name,
- arg.CloneUrl,
- arg.NormalizedUrl,
- arg.Ref,
- )
- var i Repository
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ConnectionID,
- &i.Name,
- &i.CloneUrl,
- &i.NormalizedUrl,
- &i.Ref,
- &i.CurrentCommitSha,
- &i.State,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.SyncedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const createSCMConnection = `-- name: CreateSCMConnection :one
-INSERT INTO scm_connections (id, workspace_id, type, name, base_url, auth_type, secret_ref)
-VALUES ($1, $2, $3, $4,
- $5, $6, $7)
-RETURNING id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at
-`
-
-type CreateSCMConnectionParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- Type string `json:"type"`
- Name string `json:"name"`
- BaseUrl string `json:"base_url"`
- AuthType string `json:"auth_type"`
- SecretRef pgtype.UUID `json:"secret_ref"`
-}
-
-func (q *Queries) CreateSCMConnection(ctx context.Context, arg CreateSCMConnectionParams) (ScmConnection, error) {
- row := q.db.QueryRow(ctx, createSCMConnection,
- arg.ID,
- arg.WorkspaceID,
- arg.Type,
- arg.Name,
- arg.BaseUrl,
- arg.AuthType,
- arg.SecretRef,
- )
- var i ScmConnection
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.BaseUrl,
- &i.AuthType,
- &i.SecretRef,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const getRepository = `-- name: GetRepository :one
-SELECT id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at FROM repositories
-WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL
-`
-
-type GetRepositoryParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) GetRepository(ctx context.Context, arg GetRepositoryParams) (Repository, error) {
- row := q.db.QueryRow(ctx, getRepository, arg.ID, arg.WorkspaceID)
- var i Repository
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ConnectionID,
- &i.Name,
- &i.CloneUrl,
- &i.NormalizedUrl,
- &i.Ref,
- &i.CurrentCommitSha,
- &i.State,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.SyncedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const getSCMConnection = `-- name: GetSCMConnection :one
-SELECT id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at FROM scm_connections
-WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL
-`
-
-type GetSCMConnectionParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) GetSCMConnection(ctx context.Context, arg GetSCMConnectionParams) (ScmConnection, error) {
- row := q.db.QueryRow(ctx, getSCMConnection, arg.ID, arg.WorkspaceID)
- var i ScmConnection
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.BaseUrl,
- &i.AuthType,
- &i.SecretRef,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const getWorkspaceOverview = `-- name: GetWorkspaceOverview :one
-SELECT
- (SELECT count(*) FROM repositories AS repository
- WHERE repository.workspace_id = $1 AND repository.deleted_at IS NULL)::bigint AS repository_count,
- (SELECT count(*) FROM channel_instances AS channel
- WHERE channel.workspace_id = $1 AND channel.enabled = true AND channel.deleted_at IS NULL)::bigint AS active_channel_count,
- (SELECT count(*) FROM jobs AS job
- WHERE job.workspace_id = $1 AND job.status = 'failed')::bigint AS failed_job_count
-`
-
-type GetWorkspaceOverviewRow struct {
- RepositoryCount int64 `json:"repository_count"`
- ActiveChannelCount int64 `json:"active_channel_count"`
- FailedJobCount int64 `json:"failed_job_count"`
-}
-
-func (q *Queries) GetWorkspaceOverview(ctx context.Context, targetWorkspaceID pgtype.UUID) (GetWorkspaceOverviewRow, error) {
- row := q.db.QueryRow(ctx, getWorkspaceOverview, targetWorkspaceID)
- var i GetWorkspaceOverviewRow
- err := row.Scan(&i.RepositoryCount, &i.ActiveChannelCount, &i.FailedJobCount)
- return i, err
-}
-
-const listRepositories = `-- name: ListRepositories :many
-SELECT id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at FROM repositories
-WHERE workspace_id = $1 AND deleted_at IS NULL
-ORDER BY created_at DESC, id DESC
-`
-
-func (q *Queries) ListRepositories(ctx context.Context, workspaceID pgtype.UUID) ([]Repository, error) {
- rows, err := q.db.Query(ctx, listRepositories, workspaceID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []Repository{}
- for rows.Next() {
- var i Repository
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ConnectionID,
- &i.Name,
- &i.CloneUrl,
- &i.NormalizedUrl,
- &i.Ref,
- &i.CurrentCommitSha,
- &i.State,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.SyncedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const listSCMConnections = `-- name: ListSCMConnections :many
-SELECT id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at FROM scm_connections
-WHERE workspace_id = $1 AND deleted_at IS NULL
-ORDER BY created_at DESC, id DESC
-`
-
-func (q *Queries) ListSCMConnections(ctx context.Context, workspaceID pgtype.UUID) ([]ScmConnection, error) {
- rows, err := q.db.Query(ctx, listSCMConnections, workspaceID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- items := []ScmConnection{}
- for rows.Next() {
- var i ScmConnection
- if err := rows.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.BaseUrl,
- &i.AuthType,
- &i.SecretRef,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- ); err != nil {
- return nil, err
- }
- items = append(items, i)
- }
- if err := rows.Err(); err != nil {
- return nil, err
- }
- return items, nil
-}
-
-const markRepositoryFailed = `-- name: MarkRepositoryFailed :execrows
-UPDATE repositories SET state = 'failed', last_error_code = $1,
- last_error_message = $2, updated_at = now()
-WHERE id = $3 AND workspace_id = $4 AND deleted_at IS NULL
-`
-
-type MarkRepositoryFailedParams struct {
- ErrorCode string `json:"error_code"`
- ErrorMessage string `json:"error_message"`
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) MarkRepositoryFailed(ctx context.Context, arg MarkRepositoryFailedParams) (int64, error) {
- result, err := q.db.Exec(ctx, markRepositoryFailed,
- arg.ErrorCode,
- arg.ErrorMessage,
- arg.ID,
- arg.WorkspaceID,
- )
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const markRepositoryReady = `-- name: MarkRepositoryReady :execrows
-UPDATE repositories SET state = 'ready', current_commit_sha = $1, synced_at = now(),
- last_error_code = '', last_error_message = '', updated_at = now()
-WHERE id = $2 AND workspace_id = $3 AND deleted_at IS NULL
-`
-
-type MarkRepositoryReadyParams struct {
- CommitSha string `json:"commit_sha"`
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) MarkRepositoryReady(ctx context.Context, arg MarkRepositoryReadyParams) (int64, error) {
- result, err := q.db.Exec(ctx, markRepositoryReady, arg.CommitSha, arg.ID, arg.WorkspaceID)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const markRepositorySyncing = `-- name: MarkRepositorySyncing :execrows
-UPDATE repositories SET state = 'syncing', last_error_code = '', last_error_message = '', updated_at = now()
-WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL
-`
-
-type MarkRepositorySyncingParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) MarkRepositorySyncing(ctx context.Context, arg MarkRepositorySyncingParams) (int64, error) {
- result, err := q.db.Exec(ctx, markRepositorySyncing, arg.ID, arg.WorkspaceID)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const reserveRepositorySyncCooldown = `-- name: ReserveRepositorySyncCooldown :one
-INSERT INTO repository_sync_controls (repository_id, workspace_id, next_allowed_at)
-VALUES ($1, $2, $3)
-ON CONFLICT (repository_id) DO UPDATE
-SET next_allowed_at = EXCLUDED.next_allowed_at, updated_at = now()
-WHERE repository_sync_controls.workspace_id = EXCLUDED.workspace_id
- AND repository_sync_controls.next_allowed_at <= now()
-RETURNING repository_id
-`
-
-type ReserveRepositorySyncCooldownParams struct {
- RepositoryID pgtype.UUID `json:"repository_id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- NextAllowedAt pgtype.Timestamptz `json:"next_allowed_at"`
-}
-
-func (q *Queries) ReserveRepositorySyncCooldown(ctx context.Context, arg ReserveRepositorySyncCooldownParams) (pgtype.UUID, error) {
- row := q.db.QueryRow(ctx, reserveRepositorySyncCooldown, arg.RepositoryID, arg.WorkspaceID, arg.NextAllowedAt)
- var repository_id pgtype.UUID
- err := row.Scan(&repository_id)
- return repository_id, err
-}
-
-const setRepositoryRef = `-- name: SetRepositoryRef :execrows
-UPDATE repositories SET ref = $1, updated_at = now()
-WHERE id = $2 AND workspace_id = $3 AND deleted_at IS NULL
-`
-
-type SetRepositoryRefParams struct {
- Ref string `json:"ref"`
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) SetRepositoryRef(ctx context.Context, arg SetRepositoryRefParams) (int64, error) {
- result, err := q.db.Exec(ctx, setRepositoryRef, arg.Ref, arg.ID, arg.WorkspaceID)
- if err != nil {
- return 0, err
- }
- return result.RowsAffected(), nil
-}
-
-const softDeleteRepository = `-- name: SoftDeleteRepository :one
-UPDATE repositories
-SET state = 'deleting', deleted_at = now(), updated_at = now()
-WHERE id = $1 AND workspace_id = $2 AND deleted_at IS NULL
-RETURNING id, workspace_id, connection_id, name, clone_url, normalized_url, ref, current_commit_sha, state, last_error_code, last_error_message, synced_at, created_at, updated_at, deleted_at
-`
-
-type SoftDeleteRepositoryParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) SoftDeleteRepository(ctx context.Context, arg SoftDeleteRepositoryParams) (Repository, error) {
- row := q.db.QueryRow(ctx, softDeleteRepository, arg.ID, arg.WorkspaceID)
- var i Repository
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ConnectionID,
- &i.Name,
- &i.CloneUrl,
- &i.NormalizedUrl,
- &i.Ref,
- &i.CurrentCommitSha,
- &i.State,
- &i.LastErrorCode,
- &i.LastErrorMessage,
- &i.SyncedAt,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const softDeleteSCMConnection = `-- name: SoftDeleteSCMConnection :one
-UPDATE scm_connections AS connection
-SET secret_ref = NULL, deleted_at = now(), updated_at = now()
-WHERE connection.id = $1 AND connection.workspace_id = $2
- AND connection.deleted_at IS NULL
- AND NOT EXISTS (
- SELECT 1 FROM repositories AS repository
- WHERE repository.connection_id = connection.id AND repository.deleted_at IS NULL
- )
-RETURNING connection.id, connection.workspace_id, connection.type, connection.name, connection.base_url, connection.auth_type, connection.secret_ref, connection.created_at, connection.updated_at, connection.deleted_at
-`
-
-type SoftDeleteSCMConnectionParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) SoftDeleteSCMConnection(ctx context.Context, arg SoftDeleteSCMConnectionParams) (ScmConnection, error) {
- row := q.db.QueryRow(ctx, softDeleteSCMConnection, arg.ID, arg.WorkspaceID)
- var i ScmConnection
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.BaseUrl,
- &i.AuthType,
- &i.SecretRef,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
-
-const updateSCMConnection = `-- name: UpdateSCMConnection :one
-UPDATE scm_connections
-SET name = $1, base_url = $2, auth_type = $3,
- secret_ref = $4, updated_at = now()
-WHERE id = $5 AND workspace_id = $6 AND deleted_at IS NULL
-RETURNING id, workspace_id, type, name, base_url, auth_type, secret_ref, created_at, updated_at, deleted_at
-`
-
-type UpdateSCMConnectionParams struct {
- Name string `json:"name"`
- BaseUrl string `json:"base_url"`
- AuthType string `json:"auth_type"`
- SecretRef pgtype.UUID `json:"secret_ref"`
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) UpdateSCMConnection(ctx context.Context, arg UpdateSCMConnectionParams) (ScmConnection, error) {
- row := q.db.QueryRow(ctx, updateSCMConnection,
- arg.Name,
- arg.BaseUrl,
- arg.AuthType,
- arg.SecretRef,
- arg.ID,
- arg.WorkspaceID,
- )
- var i ScmConnection
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.Type,
- &i.Name,
- &i.BaseUrl,
- &i.AuthType,
- &i.SecretRef,
- &i.CreatedAt,
- &i.UpdatedAt,
- &i.DeletedAt,
- )
- return i, err
-}
diff --git a/internal/repository/postgres/sqlc/secrets.sql.go b/internal/repository/postgres/sqlc/secrets.sql.go
deleted file mode 100644
index 87036ac..0000000
--- a/internal/repository/postgres/sqlc/secrets.sql.go
+++ /dev/null
@@ -1,109 +0,0 @@
-// Code generated by sqlc. DO NOT EDIT.
-// versions:
-// sqlc v1.29.0
-// source: secrets.sql
-
-package db
-
-import (
- "context"
-
- "github.com/jackc/pgx/v5/pgtype"
-)
-
-const createSecret = `-- name: CreateSecret :one
-INSERT INTO secrets (
- id, workspace_id, resource_type, resource_id, ciphertext, nonce,
- wrapped_key, wrapped_key_nonce, key_version
-) VALUES (
- $1, $2, $3, $4,
- $5, $6, $7,
- $8, $9
-)
-RETURNING id, workspace_id, resource_type, resource_id, ciphertext, nonce, wrapped_key, wrapped_key_nonce, key_version, created_at, updated_at
-`
-
-type CreateSecretParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
- ResourceType string `json:"resource_type"`
- ResourceID pgtype.UUID `json:"resource_id"`
- Ciphertext []byte `json:"ciphertext"`
- Nonce []byte `json:"nonce"`
- WrappedKey []byte `json:"wrapped_key"`
- WrappedKeyNonce []byte `json:"wrapped_key_nonce"`
- KeyVersion int32 `json:"key_version"`
-}
-
-func (q *Queries) CreateSecret(ctx context.Context, arg CreateSecretParams) (Secret, error) {
- row := q.db.QueryRow(ctx, createSecret,
- arg.ID,
- arg.WorkspaceID,
- arg.ResourceType,
- arg.ResourceID,
- arg.Ciphertext,
- arg.Nonce,
- arg.WrappedKey,
- arg.WrappedKeyNonce,
- arg.KeyVersion,
- )
- var i Secret
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ResourceType,
- &i.ResourceID,
- &i.Ciphertext,
- &i.Nonce,
- &i.WrappedKey,
- &i.WrappedKeyNonce,
- &i.KeyVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
-
-const deleteSecret = `-- name: DeleteSecret :exec
-DELETE FROM secrets
-WHERE id = $1 AND workspace_id = $2
-`
-
-type DeleteSecretParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) DeleteSecret(ctx context.Context, arg DeleteSecretParams) error {
- _, err := q.db.Exec(ctx, deleteSecret, arg.ID, arg.WorkspaceID)
- return err
-}
-
-const getSecret = `-- name: GetSecret :one
-SELECT id, workspace_id, resource_type, resource_id, ciphertext, nonce, wrapped_key, wrapped_key_nonce, key_version, created_at, updated_at FROM secrets
-WHERE id = $1 AND workspace_id = $2
-`
-
-type GetSecretParams struct {
- ID pgtype.UUID `json:"id"`
- WorkspaceID pgtype.UUID `json:"workspace_id"`
-}
-
-func (q *Queries) GetSecret(ctx context.Context, arg GetSecretParams) (Secret, error) {
- row := q.db.QueryRow(ctx, getSecret, arg.ID, arg.WorkspaceID)
- var i Secret
- err := row.Scan(
- &i.ID,
- &i.WorkspaceID,
- &i.ResourceType,
- &i.ResourceID,
- &i.Ciphertext,
- &i.Nonce,
- &i.WrappedKey,
- &i.WrappedKeyNonce,
- &i.KeyVersion,
- &i.CreatedAt,
- &i.UpdatedAt,
- )
- return i, err
-}
diff --git a/internal/repository/postgres/store.go b/internal/repository/postgres/store.go
deleted file mode 100644
index 4c57619..0000000
--- a/internal/repository/postgres/store.go
+++ /dev/null
@@ -1,79 +0,0 @@
-package postgres
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/exaring/otelpgx"
- "github.com/jackc/pgx/v5"
- "github.com/jackc/pgx/v5/pgxpool"
- "github.com/mooncode-ai/mooncode/internal/config"
- db "github.com/mooncode-ai/mooncode/internal/repository/postgres/sqlc"
- "github.com/rs/zerolog"
- "go.opentelemetry.io/otel/propagation"
- "go.opentelemetry.io/otel/trace"
-)
-
-type Store struct {
- pool *pgxpool.Pool
- queries *db.Queries
- propagator propagation.TextMapPropagator
-}
-
-func NewStore(cfg config.Config, logger zerolog.Logger, tracerProvider trace.TracerProvider, propagator propagation.TextMapPropagator) (*Store, error) {
- if cfg.Database.AutoMigrate {
- if err := MigrateUp(context.Background(), cfg.Database); err != nil {
- return nil, err
- }
- logger.Info().Str("operation", "database.migrate").Msg("PostgreSQL migrations are current")
- }
-
- poolConfig, err := pgxpool.ParseConfig(cfg.Database.URL)
- if err != nil {
- return nil, fmt.Errorf("parse database URL: %w", err)
- }
- poolConfig.MaxConns = cfg.Database.MaxConnections
- poolConfig.MinConns = cfg.Database.MinConnections
- poolConfig.MaxConnLifetime = time.Hour
- poolConfig.MaxConnIdleTime = 15 * time.Minute
- poolConfig.HealthCheckPeriod = cfg.Database.HealthInterval
- poolConfig.ConnConfig.Tracer = otelpgx.NewTracer(
- otelpgx.WithTracerProvider(tracerProvider),
- otelpgx.WithDisableSQLStatementInAttributes(),
- otelpgx.WithDisableConnectionDetailsInAttributes(),
- otelpgx.WithTrimSQLInSpanName(),
- )
-
- connectCtx, cancel := context.WithTimeout(context.Background(), cfg.Database.ConnectTimeout)
- defer cancel()
- pool, err := pgxpool.NewWithConfig(connectCtx, poolConfig)
- if err != nil {
- return nil, fmt.Errorf("create PostgreSQL pool: %w", err)
- }
- if err := pool.Ping(connectCtx); err != nil {
- pool.Close()
- return nil, fmt.Errorf("connect to PostgreSQL: %w", err)
- }
- return &Store{pool: pool, queries: db.New(pool), propagator: propagator}, nil
-}
-
-func (s *Store) Check(ctx context.Context) error {
- return s.pool.Ping(ctx)
-}
-
-func (s *Store) Close() {
- s.pool.Close()
-}
-
-func (s *Store) Pool() *pgxpool.Pool {
- return s.pool
-}
-
-func (s *Store) Queries() *db.Queries {
- return s.queries
-}
-
-func (s *Store) Begin(ctx context.Context) (pgx.Tx, error) {
- return s.pool.Begin(ctx)
-}
diff --git a/internal/repository/postgres/trace.go b/internal/repository/postgres/trace.go
deleted file mode 100644
index ce2e42d..0000000
--- a/internal/repository/postgres/trace.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package postgres
-
-import (
- "context"
- "encoding/json"
-
- "go.opentelemetry.io/otel/propagation"
-)
-
-func encodeTraceContext(ctx context.Context, propagator propagation.TextMapPropagator) []byte {
- if propagator == nil {
- return []byte(`{}`)
- }
- carrier := propagation.MapCarrier{}
- propagator.Inject(ctx, carrier)
- encoded, err := json.Marshal(carrier)
- if err != nil {
- return []byte(`{}`)
- }
- return encoded
-}
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
deleted file mode 100644
index 51367a1..0000000
--- a/internal/repository/repository.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package repository
-
-import (
- "context"
- "errors"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
-)
-
-var ErrLeaseLost = errors.New("repository: lease lost")
-
-type TimeCursor struct {
- BeforeTime *time.Time
- BeforeID *uuid.UUID
-}
-
-type OutboxStore interface {
- ClaimOutboxEvent(ctx context.Context, owner string, leaseUntil time.Time) (model.OutboxEvent, error)
- CompleteOutboxEvent(ctx context.Context, event model.OutboxEvent) error
- RetryOutboxEvent(ctx context.Context, event model.OutboxEvent, nextAttempt time.Time, code, message string) error
- CountOutboxBacklog(ctx context.Context) (int64, error)
-}
-
-type JobStore interface {
- ClaimJob(ctx context.Context, types []string, owner string, leaseUntil time.Time) (model.Job, error)
- RenewJobLease(ctx context.Context, job model.Job, leaseUntil time.Time) error
- CompleteJob(ctx context.Context, job model.Job) error
- RetryJob(ctx context.Context, job model.Job, runAfter time.Time, code, message string) error
- FailJob(ctx context.Context, job model.Job, code, message string) error
- ListJobQueueDepths(ctx context.Context, types []string) ([]model.JobQueueDepth, error)
-}
-
-type MetadataStore interface {
- JobStore
- WithinMetadataTx(ctx context.Context, fn func(MetadataStore) error) error
- CreateSCMConnection(ctx context.Context, connection model.SCMConnection) (model.SCMConnection, error)
- ListSCMConnections(ctx context.Context, workspaceID uuid.UUID) ([]model.SCMConnection, error)
- GetSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error)
- UpdateSCMConnection(ctx context.Context, connection model.SCMConnection) (model.SCMConnection, error)
- SoftDeleteSCMConnection(ctx context.Context, workspaceID, id uuid.UUID) (model.SCMConnection, error)
- CreateRepository(ctx context.Context, repository model.Repository) (model.Repository, error)
- ListRepositories(ctx context.Context, workspaceID uuid.UUID) ([]model.Repository, error)
- GetRepository(ctx context.Context, workspaceID, id uuid.UUID) (model.Repository, error)
- SetRepositoryRef(ctx context.Context, workspaceID, id uuid.UUID, ref string) error
- ReserveRepositorySync(ctx context.Context, workspaceID, repositoryID uuid.UUID, nextAllowedAt time.Time) error
- CreateJob(ctx context.Context, job model.Job, idempotencyKey string) (model.Job, error)
- GetJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error)
- CancelJob(ctx context.Context, workspaceID, id uuid.UUID) (model.Job, error)
- CancelActiveRepositorySyncJob(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Job, error)
- CancelRepositorySyncJobs(ctx context.Context, workspaceID, repositoryID uuid.UUID) error
- SoftDeleteRepository(ctx context.Context, workspaceID, repositoryID uuid.UUID) (model.Repository, error)
- MarkRepositorySyncing(ctx context.Context, job model.Job, repositoryID uuid.UUID) error
- MarkRepositoryFailed(ctx context.Context, workspaceID, repositoryID uuid.UUID, code, message string) error
- MarkSyncReady(ctx context.Context, job model.Job, repositoryID uuid.UUID, commitSHA string) error
- MarkSyncFailed(ctx context.Context, job model.Job, repositoryID uuid.UUID, code, message string) error
- AppendAudit(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, metadata []byte) error
- AppendAuditResult(ctx context.Context, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, result string, metadata []byte) error
- ListAuditLogs(ctx context.Context, workspaceID uuid.UUID, cursor TimeCursor, limit int32) ([]model.AuditLog, error)
- GetWorkspaceOverview(ctx context.Context, workspaceID uuid.UUID) (model.WorkspaceOverview, error)
-}
diff --git a/internal/repository/workflow/runner.go b/internal/repository/workflow/runner.go
new file mode 100644
index 0000000..323e492
--- /dev/null
+++ b/internal/repository/workflow/runner.go
@@ -0,0 +1,180 @@
+package workflow
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type Authorizer interface {
+ Membership(context.Context, auth.Actor, uuid.UUID, string) (identity.Membership, error)
+}
+
+type Credentials interface {
+ Credential(context.Context, uuid.UUID, uuid.UUID, int64) (identity.ProviderConnection, identity.ProviderCredential, error)
+}
+
+type Runner struct {
+ store repository.Store
+ authorizer Authorizer
+ providers Credentials
+ git gitrepo.Manager
+}
+
+func NewRunner(store repository.Store, authorizer Authorizer, providers Credentials, git gitrepo.Manager) *Runner {
+ return &Runner{store: store, authorizer: authorizer, providers: providers, git: git}
+}
+
+func (r *Runner) Execute(ctx context.Context, operationID uuid.UUID) error {
+ work, err := r.store.GetWorkItem(ctx, operationID)
+ if err != nil {
+ return fmt.Errorf("load repository operation: %w", err)
+ }
+ if work.Operation.Status == "queued" {
+ if _, err = r.store.StartOperation(ctx, operationID); err != nil {
+ return fmt.Errorf("start repository operation: %w", err)
+ }
+ } else if work.Operation.Status == "succeeded" {
+ return r.releaseRedundantSnapshot(ctx, work)
+ } else if work.Operation.Status != "running" {
+ return nil
+ }
+ if work.Operation.RepositoryVersion != work.Repository.ConfigVersion {
+ return workflowbiz.Permanent(errors.New("repository configuration changed after operation was requested"))
+ }
+
+ if work.Operation.Kind == "purge" {
+ retained, err := r.store.HasRetainedAnalysis(ctx, work.Repository.ID)
+ if err != nil {
+ return fmt.Errorf("check retained repository analyses: %w", err)
+ }
+ if retained {
+ return r.store.CompleteRetainedDeletion(ctx, work)
+ }
+ if err := r.git.Purge(ctx, work.Repository.ID); err != nil {
+ return err
+ }
+ return r.store.CompletePurge(ctx, work)
+ }
+ actor := auth.Actor{UserID: work.Operation.ActorUserID}
+ if _, err := r.authorizer.Membership(ctx, actor, work.Repository.WorkspaceID, "member"); err != nil {
+ return workflowbiz.Permanent(errors.New("requesting user no longer has workspace permission"))
+ }
+ gitCredential := gitrepo.Credential{}
+ if work.Operation.ProviderConnectionID != nil {
+ connection, credential, credentialErr := r.providers.Credential(ctx, work.Operation.ActorUserID, *work.Operation.ProviderConnectionID, work.Operation.CredentialVersion)
+ if credentialErr != nil {
+ cause := fmt.Errorf("load current user's provider credential: %w", credentialErr)
+ problem, isProblem := fault.From(credentialErr)
+ if errors.Is(credentialErr, pgx.ErrNoRows) || (isProblem && (problem.Code() == "provider.credential_stale" || problem.Code() == "provider.connection_unavailable")) {
+ return workflowbiz.Permanent(cause)
+ }
+
+ return cause
+ }
+ defer clear(credential.Token)
+ if connection.ProviderType != work.Operation.RequestedProviderType || connection.Status != "active" {
+ return workflowbiz.Permanent(errors.New("provider connection is no longer valid for this repository"))
+ }
+ gitCredential = gitrepo.Credential{Username: connection.Login, Token: credential.Token}
+ }
+ snapshotID := work.Operation.ID
+ var snapshot gitrepo.Snapshot
+ if work.Operation.Kind == "provision" {
+ snapshot, err = r.git.Provision(ctx, work.Repository.ID, work.Operation.RequestedRemoteURL, work.Operation.RequestedRef, snapshotID, gitCredential)
+ } else {
+ snapshot, err = r.git.Sync(ctx, work.Repository.ID, work.Operation.RequestedRemoteURL, work.Operation.RequestedRef, snapshotID, gitCredential)
+ }
+ if err != nil {
+ if gitrepo.IsPermanent(err) {
+ return workflowbiz.Permanent(err)
+ }
+
+ return err
+ }
+ return r.completeSync(ctx, work, snapshotID, snapshot)
+}
+
+func (r *Runner) completeSync(ctx context.Context, work repository.WorkItem, snapshotID uuid.UUID, snapshot gitrepo.Snapshot) error {
+ operation, err := r.store.CompleteSync(ctx, work, snapshotID, snapshot)
+ if err != nil {
+ return r.reconcileSnapshot(work, snapshotID, snapshot, err)
+ }
+
+ work.Operation = operation
+
+ return r.releaseRedundantSnapshot(ctx, work)
+}
+
+func (r *Runner) releaseRedundantSnapshot(ctx context.Context, work repository.WorkItem) error {
+ operation := work.Operation
+ if operation.SnapshotID == nil || *operation.SnapshotID == operation.ID {
+ return nil
+ }
+ if operation.ResolvedCommitSHA == "" {
+ return workflowbiz.Permanent(errors.New("completed repository operation has no resolved commit"))
+ }
+ if err := r.git.ReleaseSnapshot(ctx, work.Repository.ID, operation.ID, operation.ResolvedCommitSHA); err != nil {
+ return fmt.Errorf("release redundant snapshot pin: %w", err)
+ }
+
+ return nil
+}
+
+func (r *Runner) reconcileSnapshot(work repository.WorkItem, snapshotID uuid.UUID, snapshot gitrepo.Snapshot, completionErr error) error {
+ reconcileContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ persisted, err := r.store.Snapshot(reconcileContext, work.Repository.WorkspaceID, work.Repository.ID, snapshotID)
+ if err == nil {
+ if !strings.EqualFold(persisted.CommitSHA, snapshot.CommitSHA) {
+ return workflowbiz.Permanent(errors.New("persisted snapshot commit does not match the Git pin"))
+ }
+
+ return nil
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return completionErr
+ }
+ if err := r.git.ReleaseSnapshot(reconcileContext, work.Repository.ID, snapshotID, snapshot.CommitSHA); err != nil {
+ return errors.Join(completionErr, fmt.Errorf("release uncommitted snapshot pin: %w", err))
+ }
+
+ return completionErr
+}
+
+func (r *Runner) MarkFailed(ctx context.Context, operationID uuid.UUID, cause error) error {
+ work, err := r.store.GetWorkItem(ctx, operationID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ failure := repository.Failure{Code: "repository.sync_failed", Message: "Repository synchronization failed"}
+ if work.Operation.Kind == "purge" {
+ failure = repository.Failure{Code: "repository.purge_failed", Message: "Repository deletion failed"}
+ }
+ if errors.Is(cause, gitrepo.ErrMirrorQuota) {
+ failure = repository.Failure{Code: "repository.mirror_quota_exceeded", Message: "Repository mirror exceeds the configured storage limit"}
+ }
+ if errors.Is(cause, gitrepo.ErrRemoteBlocked) {
+ failure = repository.Failure{Code: "repository.remote_blocked", Message: "Repository remote is blocked by network policy"}
+ }
+ if errors.Is(cause, gitrepo.ErrAuthentication) {
+ failure = repository.Failure{Code: "repository.authentication_failed", Message: "Repository authentication failed; add or update a personal access token"}
+ }
+
+ return r.store.FailOperation(ctx, work, failure)
+}
diff --git a/internal/repository/workflow/runner_test.go b/internal/repository/workflow/runner_test.go
new file mode 100644
index 0000000..ac39bd2
--- /dev/null
+++ b/internal/repository/workflow/runner_test.go
@@ -0,0 +1,532 @@
+package workflow
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ identity "github.com/fuchencong/mooncode/internal/identity/biz"
+ "github.com/fuchencong/mooncode/internal/platform/auth"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ repository "github.com/fuchencong/mooncode/internal/repository/biz"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type repositoryWorkflowStore struct {
+ repository.Store
+ work repository.WorkItem
+ started bool
+ completed bool
+ purged bool
+ retained bool
+ logicallyDeleted bool
+ failure repository.Failure
+ completedSnapshot gitrepo.Snapshot
+ completeOperation repository.Operation
+ snapshotID uuid.UUID
+ completeErr error
+ persistedSnapshot repository.Snapshot
+ snapshotLookupErr error
+}
+
+func (s *repositoryWorkflowStore) GetWorkItem(context.Context, uuid.UUID) (repository.WorkItem, error) {
+ return s.work, nil
+}
+
+func (s *repositoryWorkflowStore) StartOperation(context.Context, uuid.UUID) (repository.Operation, error) {
+ s.started = true
+ s.work.Operation.Status = "running"
+
+ return s.work.Operation, nil
+}
+
+func (s *repositoryWorkflowStore) Snapshot(context.Context, uuid.UUID, uuid.UUID, uuid.UUID) (repository.Snapshot, error) {
+ return s.persistedSnapshot, s.snapshotLookupErr
+}
+
+func (s *repositoryWorkflowStore) CompleteSync(_ context.Context, _ repository.WorkItem, snapshotID uuid.UUID, snapshot gitrepo.Snapshot) (repository.Operation, error) {
+ s.completed = true
+ s.snapshotID = snapshotID
+ s.completedSnapshot = snapshot
+
+ if s.completeOperation.ID != uuid.Nil {
+ return s.completeOperation, s.completeErr
+ }
+
+ return s.work.Operation, s.completeErr
+}
+
+func (s *repositoryWorkflowStore) CompletePurge(context.Context, repository.WorkItem) error {
+ s.purged = true
+
+ return nil
+}
+
+func (s *repositoryWorkflowStore) HasRetainedAnalysis(context.Context, uuid.UUID) (bool, error) {
+ return s.retained, nil
+}
+
+func (s *repositoryWorkflowStore) CompleteRetainedDeletion(context.Context, repository.WorkItem) error {
+ s.logicallyDeleted = true
+
+ return nil
+}
+
+func (s *repositoryWorkflowStore) FailOperation(_ context.Context, _ repository.WorkItem, failure repository.Failure) error {
+ s.failure = failure
+
+ return nil
+}
+
+type repositoryWorkflowAuthorizer struct {
+ actor auth.Actor
+ err error
+}
+
+func (a *repositoryWorkflowAuthorizer) Membership(_ context.Context, actor auth.Actor, _ uuid.UUID, _ string) (identity.Membership, error) {
+ a.actor = actor
+
+ return identity.Membership{Role: "member"}, a.err
+}
+
+type repositoryWorkflowCredentials struct {
+ connection identity.ProviderConnection
+ credential identity.ProviderCredential
+ err error
+ userID uuid.UUID
+ version int64
+ calls int
+}
+
+func (p *repositoryWorkflowCredentials) Credential(_ context.Context, userID, _ uuid.UUID, version int64) (identity.ProviderConnection, identity.ProviderCredential, error) {
+ p.calls++
+ p.userID = userID
+ p.version = version
+
+ return p.connection, p.credential, p.err
+}
+
+type repositoryWorkflowGit struct {
+ provisioned bool
+ synced bool
+ purged bool
+ repositoryID uuid.UUID
+ remoteURL string
+ ref string
+ snapshotID uuid.UUID
+ credential gitrepo.Credential
+ credentialToken string
+ snapshot gitrepo.Snapshot
+ released bool
+ releasedSnapshotID uuid.UUID
+ releasedCommit string
+ releaseErr error
+}
+
+func (g *repositoryWorkflowGit) Provision(_ context.Context, id uuid.UUID, remoteURL, ref string, snapshotID uuid.UUID, credential gitrepo.Credential) (gitrepo.Snapshot, error) {
+ g.provisioned = true
+ g.capture(id, remoteURL, ref, snapshotID, credential)
+
+ return g.snapshot, nil
+}
+
+func (g *repositoryWorkflowGit) Sync(_ context.Context, id uuid.UUID, remoteURL, ref string, snapshotID uuid.UUID, credential gitrepo.Credential) (gitrepo.Snapshot, error) {
+ g.synced = true
+ g.capture(id, remoteURL, ref, snapshotID, credential)
+
+ return g.snapshot, nil
+}
+
+func (*repositoryWorkflowGit) Checkout(context.Context, uuid.UUID, string) (string, func() error, error) {
+ return "", func() error { return nil }, nil
+}
+
+func (g *repositoryWorkflowGit) Purge(_ context.Context, id uuid.UUID) error {
+ g.purged = true
+ g.repositoryID = id
+
+ return nil
+}
+
+func (g *repositoryWorkflowGit) ReleaseSnapshot(_ context.Context, _ uuid.UUID, snapshotID uuid.UUID, commitSHA string) error {
+ g.released = true
+ g.releasedSnapshotID = snapshotID
+ g.releasedCommit = commitSHA
+
+ return g.releaseErr
+}
+
+func (*repositoryWorkflowGit) Path(uuid.UUID) string { return "" }
+
+func (g *repositoryWorkflowGit) capture(id uuid.UUID, remoteURL, ref string, snapshotID uuid.UUID, credential gitrepo.Credential) {
+ g.repositoryID = id
+ g.remoteURL = remoteURL
+ g.ref = ref
+ g.snapshotID = snapshotID
+ g.credential = credential
+ g.credentialToken = string(credential.Token)
+}
+
+func TestRunnerSkipsTerminalOperation(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New()},
+ Operation: repository.Operation{ID: uuid.New(), Status: "succeeded"},
+ }}
+ git := &repositoryWorkflowGit{}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if store.started || store.completed || store.purged || git.provisioned || git.synced || git.purged {
+ t.Fatal("terminal repository operation was executed again")
+ }
+}
+
+func TestRunnerProvisionUsesFrozenActorCredential(t *testing.T) {
+ actorID, workspaceID, repositoryID, connectionID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, ProviderType: "github", RemoteURL: "https://github.com/example/repo.git"},
+ Operation: repository.Operation{
+ ID: uuid.New(), ActorUserID: actorID, ProviderConnectionID: &connectionID,
+ CredentialVersion: 7, Kind: "provision", RequestedProviderType: "github",
+ RequestedRemoteURL: "https://github.com/example/repo.git", RequestedRef: "main", Status: "queued",
+ },
+ }}
+ authorizer := &repositoryWorkflowAuthorizer{}
+ providers := &repositoryWorkflowCredentials{
+ connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", Login: "octocat", Status: "active"},
+ credential: identity.ProviderCredential{Token: []byte("github_pat_secret")},
+ }
+ git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: "1234567890abcdef"}}
+ runner := NewRunner(store, authorizer, providers, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if !store.started || !store.completed || !git.provisioned || git.synced {
+ t.Fatalf("unexpected workflow state: store=%+v git=%+v", store, git)
+ }
+ if authorizer.actor.UserID != actorID || providers.userID != actorID || providers.version != 7 || providers.calls != 1 {
+ t.Fatalf("workflow did not use frozen actor credential: actor=%s provider=%+v", authorizer.actor.UserID, providers)
+ }
+ if git.credential.Username != "octocat" || git.credentialToken != "github_pat_secret" || git.ref != "main" || git.remoteURL != store.work.Operation.RequestedRemoteURL {
+ t.Fatalf("unexpected Git request: %+v", git)
+ }
+ if !allZero(providers.credential.Token) {
+ t.Fatal("provider credential plaintext was not cleared after the Git operation")
+ }
+ if store.snapshotID != store.work.Operation.ID || store.snapshotID != git.snapshotID || store.completedSnapshot.CommitSHA != git.snapshot.CommitSHA {
+ t.Fatalf("snapshot was not completed consistently: store=%+v git=%+v", store, git)
+ }
+}
+
+func TestRunnerProvisionPublicRepositoryWithoutCredential(t *testing.T) {
+ workspaceID, repositoryID := uuid.New(), uuid.New()
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: repositoryID, WorkspaceID: workspaceID, ProviderType: "github", RemoteURL: "https://github.com/example/public-repo.git"},
+ Operation: repository.Operation{
+ ID: uuid.New(), ActorUserID: uuid.New(), Kind: "provision", RequestedProviderType: "github",
+ RequestedRemoteURL: "https://github.com/example/public-repo.git", RequestedRef: "main", Status: "queued",
+ },
+ }}
+ providers := &repositoryWorkflowCredentials{}
+ git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: "1234567890abcdef"}}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if providers.calls != 0 || !git.provisioned || len(git.credential.Token) != 0 || git.credential.Username != "" {
+ t.Fatalf("public synchronization used a credential: provider=%+v git=%+v", providers, git)
+ }
+}
+
+func TestRunnerReleasesRedundantSnapshotPin(t *testing.T) {
+ operationID := uuid.New()
+ existingSnapshotID := uuid.New()
+ commitSHA := "1234567890abcdef1234567890abcdef12345678"
+ completed := repository.Operation{
+ ID: operationID, Status: "succeeded", SnapshotID: &existingSnapshotID,
+ ResolvedCommitSHA: commitSHA,
+ }
+ store := &repositoryWorkflowStore{
+ completeOperation: completed,
+ work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()},
+ Operation: repository.Operation{
+ ID: operationID, ActorUserID: uuid.New(), Kind: "refresh",
+ RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/public-repo.git",
+ RequestedRef: "main", Status: "queued",
+ },
+ },
+ }
+ git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: commitSHA}}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, git)
+
+ if err := runner.Execute(context.Background(), operationID); err != nil {
+ t.Fatal(err)
+ }
+ if !store.completed || !git.released || git.releasedSnapshotID != operationID || git.releasedCommit != commitSHA {
+ t.Fatalf("redundant snapshot pin was not released: store=%+v git=%+v", store, git)
+ }
+}
+
+func TestRunnerRetriesRedundantSnapshotCleanupForSucceededOperation(t *testing.T) {
+ operationID := uuid.New()
+ existingSnapshotID := uuid.New()
+ commitSHA := "1234567890abcdef1234567890abcdef12345678"
+ cleanupErr := errors.New("release snapshot pin")
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New()},
+ Operation: repository.Operation{
+ ID: operationID, Status: "succeeded", SnapshotID: &existingSnapshotID,
+ ResolvedCommitSHA: commitSHA,
+ },
+ }}
+ git := &repositoryWorkflowGit{releaseErr: cleanupErr}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, git)
+
+ if err := runner.Execute(context.Background(), operationID); !errors.Is(err, cleanupErr) {
+ t.Fatalf("Execute() error = %v, want cleanup error", err)
+ }
+ git.releaseErr = nil
+ if err := runner.Execute(context.Background(), operationID); err != nil {
+ t.Fatal(err)
+ }
+ if store.started || store.completed || git.provisioned || git.synced || git.releasedSnapshotID != operationID {
+ t.Fatalf("terminal cleanup reran repository synchronization: store=%+v git=%+v", store, git)
+ }
+}
+
+func TestRunnerReleasesSnapshotPinWhenDatabaseCompletionDidNotCommit(t *testing.T) {
+ connectionID := uuid.New()
+ completionErr := errors.New("complete repository operation")
+ store := &repositoryWorkflowStore{
+ completeErr: completionErr,
+ snapshotLookupErr: pgx.ErrNoRows,
+ work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1},
+ Operation: repository.Operation{
+ ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID,
+ CredentialVersion: 1, RepositoryVersion: 1, Kind: "refresh",
+ RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git",
+ RequestedRef: "main", Status: "running",
+ },
+ },
+ }
+ providers := &repositoryWorkflowCredentials{
+ connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", Login: "octocat", Status: "active"},
+ credential: identity.ProviderCredential{Token: []byte("token")},
+ }
+ git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: "1234567890abcdef1234567890abcdef12345678"}}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git)
+
+ err := runner.Execute(context.Background(), store.work.Operation.ID)
+ if !errors.Is(err, completionErr) {
+ t.Fatalf("Execute() error = %v, want completion error", err)
+ }
+ if !git.released || git.releasedCommit != git.snapshot.CommitSHA {
+ t.Fatalf("uncommitted snapshot pin was not released: %+v", git)
+ }
+}
+
+func TestRunnerAcceptsCommittedSnapshotAfterAmbiguousCompletionError(t *testing.T) {
+ connectionID := uuid.New()
+ commitSHA := "1234567890abcdef1234567890abcdef12345678"
+ store := &repositoryWorkflowStore{
+ completeErr: errors.New("commit result was lost"),
+ persistedSnapshot: repository.Snapshot{CommitSHA: commitSHA},
+ work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1},
+ Operation: repository.Operation{
+ ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID,
+ CredentialVersion: 1, RepositoryVersion: 1, Kind: "refresh",
+ RequestedProviderType: "github", RequestedRemoteURL: "https://github.com/example/repo.git",
+ RequestedRef: "main", Status: "running",
+ },
+ },
+ }
+ providers := &repositoryWorkflowCredentials{
+ connection: identity.ProviderConnection{ID: connectionID, ProviderType: "github", Login: "octocat", Status: "active"},
+ credential: identity.ProviderCredential{Token: []byte("token")},
+ }
+ git := &repositoryWorkflowGit{snapshot: gitrepo.Snapshot{CommitSHA: commitSHA}}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if git.released {
+ t.Fatal("committed snapshot pin was released")
+ }
+}
+
+func allZero(value []byte) bool {
+ for _, item := range value {
+ if item != 0 {
+ return false
+ }
+ }
+
+ return true
+}
+
+func TestRunnerPurgeIsLocalAndDoesNotLoadProviderCredential(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()},
+ Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), Kind: "purge", Status: "queued"},
+ }}
+ providers := &repositoryWorkflowCredentials{}
+ git := &repositoryWorkflowGit{}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if !git.purged || !store.purged || providers.calls != 0 {
+ t.Fatalf("purge accessed remote credential or did not complete: git=%+v store=%+v providers=%+v", git, store, providers)
+ }
+}
+
+func TestRunnerRetainsMirrorReferencedByAnalysis(t *testing.T) {
+ store := &repositoryWorkflowStore{retained: true, work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()},
+ Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), Kind: "purge", Status: "queued"},
+ }}
+ providers := &repositoryWorkflowCredentials{}
+ git := &repositoryWorkflowGit{}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if git.purged || store.purged || !store.logicallyDeleted || providers.calls != 0 {
+ t.Fatalf("retained repository deletion was unsafe: git=%+v store=%+v", git, store)
+ }
+}
+
+func TestRunnerRejectsActorWithoutCurrentWorkspaceAccess(t *testing.T) {
+ connectionID := uuid.New()
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()},
+ Operation: repository.Operation{
+ ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID,
+ Kind: "refresh", Status: "queued",
+ },
+ }}
+ git := &repositoryWorkflowGit{}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{err: errors.New("forbidden")}, &repositoryWorkflowCredentials{}, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err == nil || !workflowbiz.IsPermanent(err) {
+ t.Fatal("expected removed member to be rejected")
+ }
+ if git.purged || store.purged {
+ t.Fatal("Git mutation ran without current workspace permission")
+ }
+}
+
+func TestRunnerDoesNotRetryStaleProviderCredential(t *testing.T) {
+ connectionID := uuid.New()
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()},
+ Operation: repository.Operation{
+ ID: uuid.New(), ActorUserID: uuid.New(), ProviderConnectionID: &connectionID,
+ CredentialVersion: 2, Kind: "refresh", Status: "queued",
+ },
+ }}
+ providers := &repositoryWorkflowCredentials{err: fault.New(fault.Conflict, "provider.credential_stale", "Provider credential changed")}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, &repositoryWorkflowGit{})
+
+ err := runner.Execute(context.Background(), store.work.Operation.ID)
+ if err == nil || !workflowbiz.IsPermanent(err) {
+ t.Fatalf("stale provider credential error = %v, want permanent failure", err)
+ }
+}
+
+func TestRunnerPurgeDoesNotDependOnFormerMember(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New()},
+ Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), Kind: "purge", Status: "queued"},
+ }}
+ git := &repositoryWorkflowGit{}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{err: errors.New("former member")}, &repositoryWorkflowCredentials{}, git)
+
+ if err := runner.Execute(context.Background(), store.work.Operation.ID); err != nil {
+ t.Fatal(err)
+ }
+ if !git.purged || !store.purged {
+ t.Fatal("authorized local purge was blocked by later membership change")
+ }
+}
+
+func TestRunnerRejectsStaleRepositoryConfiguration(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 3},
+ Operation: repository.Operation{ID: uuid.New(), ActorUserID: uuid.New(), RepositoryVersion: 2, Kind: "refresh", Status: "queued"},
+ }}
+ providers := &repositoryWorkflowCredentials{}
+ git := &repositoryWorkflowGit{}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, providers, git)
+
+ err := runner.Execute(context.Background(), store.work.Operation.ID)
+ if err == nil || err.Error() != "repository configuration changed after operation was requested" {
+ t.Fatalf("Execute() error = %v, want stale configuration failure", err)
+ }
+ if !workflowbiz.IsPermanent(err) {
+ t.Fatal("stale repository operation was not classified as permanent")
+ }
+ if providers.calls != 0 || git.provisioned || git.synced || git.purged || store.completed {
+ t.Fatal("stale repository operation performed side effects")
+ }
+}
+
+func TestRunnerProjectsMirrorQuotaFailure(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1},
+ Operation: repository.Operation{ID: uuid.New(), RepositoryVersion: 1, Kind: "refresh", Status: "running"},
+ }}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, &repositoryWorkflowGit{})
+
+ err := runner.MarkFailed(context.Background(), store.work.Operation.ID, &gitrepo.MirrorQuotaError{LimitBytes: 10, ActualBytes: 11})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if store.failure.Code != "repository.mirror_quota_exceeded" || store.failure.Message != "Repository mirror exceeds the configured storage limit" {
+ t.Fatalf("unexpected quota projection: %+v", store.failure)
+ }
+}
+
+func TestRunnerProjectsBlockedRemoteFailure(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1},
+ Operation: repository.Operation{ID: uuid.New(), RepositoryVersion: 1, Kind: "provision", Status: "running"},
+ }}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, &repositoryWorkflowGit{})
+
+ err := runner.MarkFailed(context.Background(), store.work.Operation.ID, gitrepo.ErrRemoteBlocked)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if store.failure.Code != "repository.remote_blocked" || store.failure.Message != "Repository remote is blocked by network policy" {
+ t.Fatalf("unexpected blocked remote projection: %+v", store.failure)
+ }
+}
+
+func TestRunnerProjectsAuthenticationFailure(t *testing.T) {
+ store := &repositoryWorkflowStore{work: repository.WorkItem{
+ Repository: repository.Repository{ID: uuid.New(), WorkspaceID: uuid.New(), ConfigVersion: 1},
+ Operation: repository.Operation{ID: uuid.New(), RepositoryVersion: 1, Kind: "refresh", Status: "running"},
+ }}
+ runner := NewRunner(store, &repositoryWorkflowAuthorizer{}, &repositoryWorkflowCredentials{}, &repositoryWorkflowGit{})
+
+ if err := runner.MarkFailed(context.Background(), store.work.Operation.ID, gitrepo.ErrAuthentication); err != nil {
+ t.Fatal(err)
+ }
+ if store.failure.Code != "repository.authentication_failed" || store.failure.Message != "Repository authentication failed; add or update a personal access token" {
+ t.Fatalf("authentication failure projection = %+v", store.failure)
+ }
+}
diff --git a/internal/retention/biz/model.go b/internal/retention/biz/model.go
new file mode 100644
index 0000000..3c8a4be
--- /dev/null
+++ b/internal/retention/biz/model.go
@@ -0,0 +1,26 @@
+package biz
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+type Cleanup struct {
+ ID uuid.UUID
+ WorkspaceID uuid.UUID
+ ScheduledFor time.Time
+ RetentionDays int32
+ Status string
+ WorkflowRunID string
+ DeletedRunCount int32
+ PurgedSnapshotCount int32
+ RequeuedRepositoryCount int32
+ ErrorMessage string
+}
+
+type SnapshotPin struct {
+ ID uuid.UUID
+ RepositoryID uuid.UUID
+ CommitSHA string
+}
diff --git a/internal/retention/biz/store.go b/internal/retention/biz/store.go
new file mode 100644
index 0000000..6a6e756
--- /dev/null
+++ b/internal/retention/biz/store.go
@@ -0,0 +1,19 @@
+package biz
+
+import (
+ "context"
+
+ "github.com/google/uuid"
+)
+
+type Store interface {
+ ScheduleDue(context.Context, int32) (int, error)
+ GetCleanup(context.Context, uuid.UUID) (Cleanup, error)
+ StartCleanup(context.Context, uuid.UUID) (Cleanup, error)
+ DeleteExpiredRuns(context.Context, Cleanup, int32) (int, error)
+ ClaimSnapshotPins(context.Context, Cleanup, int32) ([]SnapshotPin, error)
+ CompleteSnapshotPurge(context.Context, Cleanup, SnapshotPin) error
+ RequeueRepositoryPurges(context.Context, Cleanup, int32) (int, error)
+ FinishCleanup(context.Context, uuid.UUID) error
+ FailCleanup(context.Context, uuid.UUID, string) error
+}
diff --git a/internal/retention/data/store.go b/internal/retention/data/store.go
new file mode 100644
index 0000000..3672ab0
--- /dev/null
+++ b/internal/retention/data/store.go
@@ -0,0 +1,294 @@
+package data
+
+import (
+ "context"
+ "errors"
+
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/platform/audit"
+ retention "github.com/fuchencong/mooncode/internal/retention/biz"
+ workflow "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ pool *pgxpool.Pool
+ queries *sqlc.Queries
+}
+
+func NewStore(pool *pgxpool.Pool) *Store {
+ return &Store{pool: pool, queries: sqlc.New(pool)}
+}
+
+func (s *Store) ScheduleDue(ctx context.Context, limit int32) (int, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ q := s.queries.WithTx(tx)
+ schedules, err := q.ClaimDueRetentionSchedules(ctx, limit)
+ if err != nil {
+ return 0, err
+ }
+ for _, schedule := range schedules {
+ cleanupID := uuid.New()
+ if _, err = q.CreateRetentionCleanup(ctx, sqlc.CreateRetentionCleanupParams{
+ ID: cleanupID, WorkspaceID: schedule.WorkspaceID,
+ ScheduledFor: schedule.NextRunAt, RetentionDays: schedule.ReportRetentionDays,
+ }); err != nil {
+ return 0, err
+ }
+
+ dispatch, dispatchErr := workflow.NewDispatch(
+ workflow.AggregateRetentionCleanup,
+ cleanupID,
+ workflow.WorkflowRetentionCleanup,
+ workflow.Payload{AggregateID: cleanupID, WorkspaceID: schedule.WorkspaceID},
+ )
+ if dispatchErr != nil {
+ return 0, dispatchErr
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{
+ ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID,
+ WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload,
+ }); err != nil {
+ return 0, err
+ }
+
+ advanced, advanceErr := q.AdvanceRetentionSchedule(ctx, sqlc.AdvanceRetentionScheduleParams{
+ WorkspaceID: schedule.WorkspaceID, NextRunAt: schedule.NextRunAt,
+ })
+ if advanceErr != nil {
+ return 0, advanceErr
+ }
+ if advanced != 1 {
+ return 0, errors.New("retention schedule changed while locked")
+ }
+ }
+
+ if err = tx.Commit(ctx); err != nil {
+ return 0, err
+ }
+
+ return len(schedules), nil
+}
+
+func (s *Store) GetCleanup(ctx context.Context, id uuid.UUID) (retention.Cleanup, error) {
+ row, err := s.queries.GetRetentionCleanup(ctx, id)
+
+ return mapCleanup(row), err
+}
+
+func (s *Store) StartCleanup(ctx context.Context, id uuid.UUID) (retention.Cleanup, error) {
+ row, err := s.queries.StartRetentionCleanup(ctx, id)
+
+ return mapCleanup(row), err
+}
+
+func (s *Store) DeleteExpiredRuns(ctx context.Context, cleanup retention.Cleanup, limit int32) (int, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ q := s.queries.WithTx(tx)
+ snapshotIDs, err := q.DeleteExpiredAnalysisRuns(ctx, sqlc.DeleteExpiredAnalysisRunsParams{
+ WorkspaceID: cleanup.WorkspaceID, RetentionDays: cleanup.RetentionDays, Limit: limit,
+ })
+ if err != nil {
+ return 0, err
+ }
+ if len(snapshotIDs) > 0 {
+ updated, updateErr := q.AddRetentionCleanupProgress(ctx, sqlc.AddRetentionCleanupProgressParams{
+ ID: cleanup.ID, DeletedRuns: int32(len(snapshotIDs)),
+ })
+ if updateErr != nil {
+ return 0, updateErr
+ }
+ if updated != 1 {
+ return 0, errors.New("retention cleanup is not running")
+ }
+ }
+
+ if err = tx.Commit(ctx); err != nil {
+ return 0, err
+ }
+
+ return len(snapshotIDs), nil
+}
+
+func (s *Store) ClaimSnapshotPins(ctx context.Context, cleanup retention.Cleanup, limit int32) ([]retention.SnapshotPin, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ q := s.queries.WithTx(tx)
+ cleanupID := uuid.NullUUID{UUID: cleanup.ID, Valid: true}
+ candidates, err := q.LockPurgeableSnapshotCandidates(ctx, sqlc.LockPurgeableSnapshotCandidatesParams{
+ WorkspaceID: cleanup.WorkspaceID, CleanupID: cleanupID, Limit: limit,
+ })
+ if err != nil {
+ return nil, err
+ }
+ if len(candidates) == 0 {
+ if err = tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+
+ return []retention.SnapshotPin{}, nil
+ }
+ rows, err := q.MarkSnapshotsPurging(ctx, sqlc.MarkSnapshotsPurgingParams{
+ CleanupID: cleanupID, SnapshotIds: candidates, WorkspaceID: cleanup.WorkspaceID,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]retention.SnapshotPin, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, retention.SnapshotPin{ID: row.ID, RepositoryID: row.RepositoryID, CommitSHA: row.CommitSha})
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return nil, err
+ }
+
+ return items, nil
+}
+
+func (s *Store) CompleteSnapshotPurge(ctx context.Context, cleanup retention.Cleanup, pin retention.SnapshotPin) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ q := s.queries.WithTx(tx)
+ updated, err := q.CompleteSnapshotPurge(ctx, sqlc.CompleteSnapshotPurgeParams{
+ ID: pin.ID, RepositoryID: pin.RepositoryID,
+ PurgeCleanupID: uuid.NullUUID{UUID: cleanup.ID, Valid: true},
+ })
+ if err != nil {
+ return err
+ }
+ if updated == 0 {
+ return nil
+ }
+ if _, err = q.AddRetentionCleanupProgress(ctx, sqlc.AddRetentionCleanupProgressParams{
+ ID: cleanup.ID, PurgedSnapshots: 1,
+ }); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (s *Store) RequeueRepositoryPurges(ctx context.Context, cleanup retention.Cleanup, limit int32) (int, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return 0, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ q := s.queries.WithTx(tx)
+ repositories, err := q.ClaimRepositoriesReadyForPurge(ctx, sqlc.ClaimRepositoriesReadyForPurgeParams{
+ WorkspaceID: cleanup.WorkspaceID, Limit: limit,
+ })
+ if err != nil {
+ return 0, err
+ }
+ for _, repository := range repositories {
+ updated, updateErr := q.ReactivateRepositoryPurge(ctx, repository.ID)
+ if updateErr != nil {
+ return 0, updateErr
+ }
+ if updated != 1 {
+ return 0, errors.New("deleted repository changed while locked")
+ }
+
+ operationID := uuid.New()
+ if _, err = q.CreateRepositoryOperation(ctx, sqlc.CreateRepositoryOperationParams{
+ ID: operationID, RepositoryID: repository.ID, ActorUserID: repository.CreatedBy,
+ RepositoryVersion: repository.ConfigVersion, Kind: "purge",
+ RequestedProviderType: repository.ProviderType, RequestedRemoteUrl: repository.RemoteUrl,
+ RequestedNormalizedUrl: repository.NormalizedUrl, RequestedRef: repository.ConfiguredRef,
+ PreviousProviderType: repository.ProviderType, PreviousRemoteUrl: repository.RemoteUrl,
+ PreviousNormalizedUrl: repository.NormalizedUrl, PreviousRef: repository.ConfiguredRef,
+ }); err != nil {
+ return 0, err
+ }
+ dispatch, dispatchErr := workflow.NewDispatch(
+ workflow.AggregateRepositoryOperation,
+ operationID,
+ workflow.WorkflowRepositoryOperation,
+ workflow.Payload{AggregateID: operationID, RepositoryID: repository.ID},
+ )
+ if dispatchErr != nil {
+ return 0, dispatchErr
+ }
+ if _, err = q.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{
+ ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID,
+ WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload,
+ }); err != nil {
+ return 0, err
+ }
+ if err = audit.Record(ctx, q, audit.Event{
+ WorkspaceID: cleanup.WorkspaceID,
+ Action: audit.ActionRepositoryPurgeRequeued,
+ Resource: audit.ResourceRepository,
+ ResourceID: repository.ID,
+ Metadata: map[string]any{"cleanupId": cleanup.ID, "operationId": operationID},
+ }); err != nil {
+ return 0, err
+ }
+ }
+ if len(repositories) > 0 {
+ updated, updateErr := q.AddRequeuedRepositoryCount(ctx, sqlc.AddRequeuedRepositoryCountParams{
+ ID: cleanup.ID, RequeuedRepositoryCount: int32(len(repositories)),
+ })
+ if updateErr != nil {
+ return 0, updateErr
+ }
+ if updated != 1 {
+ return 0, errors.New("retention cleanup is not running")
+ }
+ }
+
+ if err = tx.Commit(ctx); err != nil {
+ return 0, err
+ }
+
+ return len(repositories), nil
+}
+
+func (s *Store) FinishCleanup(ctx context.Context, id uuid.UUID) error {
+ _, err := s.queries.FinishRetentionCleanup(ctx, id)
+
+ return err
+}
+
+func (s *Store) FailCleanup(ctx context.Context, id uuid.UUID, message string) error {
+ if len(message) > 1000 {
+ message = message[:1000]
+ }
+ _, err := s.queries.FailRetentionCleanup(ctx, sqlc.FailRetentionCleanupParams{
+ ID: id, ErrorMessage: pgtype.Text{String: message, Valid: message != ""},
+ })
+
+ return err
+}
+
+func mapCleanup(row sqlc.RetentionCleanup) retention.Cleanup {
+ return retention.Cleanup{
+ ID: row.ID, WorkspaceID: row.WorkspaceID, ScheduledFor: row.ScheduledFor.Time,
+ RetentionDays: row.RetentionDays, Status: row.Status, WorkflowRunID: row.WorkflowRunID.String,
+ DeletedRunCount: row.DeletedRunCount, PurgedSnapshotCount: row.PurgedSnapshotCount,
+ RequeuedRepositoryCount: row.RequeuedRepositoryCount, ErrorMessage: row.ErrorMessage.String,
+ }
+}
diff --git a/internal/retention/data/store_integration_test.go b/internal/retention/data/store_integration_test.go
new file mode 100644
index 0000000..a876b46
--- /dev/null
+++ b/internal/retention/data/store_integration_test.go
@@ -0,0 +1,199 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ analysis "github.com/fuchencong/mooncode/internal/analysis/biz"
+ analysisdata "github.com/fuchencong/mooncode/internal/analysis/data"
+ "github.com/fuchencong/mooncode/internal/platform/fault"
+ retention "github.com/fuchencong/mooncode/internal/retention/biz"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+func TestRetentionCleanupReleasesReferencesAndRequeuesRepositoryPurge(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID, profileID := uuid.New(), uuid.New(), uuid.New()
+ repositoryID, deletedRepositoryID := uuid.New(), uuid.New()
+ expiredSnapshotID, sharedSnapshotID, currentSnapshotID, deletedCurrentSnapshotID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ cleanupID, channelID := uuid.New(), uuid.New()
+ profileDefinition := analysis.DefaultProfileDefinition()
+ statements := []struct {
+ query string
+ args []any
+ }{
+ {`INSERT INTO users (id,status) VALUES ($1,'active')`, []any{userID}},
+ {`INSERT INTO workspaces (id,name,slug,report_retention_days,created_by) VALUES ($1,'Retention',$2,30,$3)`, []any{workspaceID, "retention-" + workspaceID.String(), userID}},
+ {`INSERT INTO analysis_profiles (id,workspace_id,name,current_version,created_by) VALUES ($1,$2,'Code scale',1,$3)`, []any{profileID, workspaceID, userID}},
+ {`INSERT INTO analysis_profile_versions (id,workspace_id,profile_id,version,dimension_key,definition,created_by) VALUES ($1,$2,$3,1,'code_scale',$4,$5)`, []any{uuid.New(), workspaceID, profileID, profileDefinition, userID}},
+ {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','active','https://github.com/example/active.git',$3,'main',$4,'ready',$5)`, []any{repositoryID, workspaceID, "github.com/example/active-" + repositoryID.String(), "/tmp/" + repositoryID.String() + ".git", userID}},
+ {`INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,deleted_at,created_by) VALUES ($1,$2,'github','deleted','https://github.com/example/deleted.git',$3,'main',$4,'deleted',clock_timestamp(),$5)`, []any{deletedRepositoryID, workspaceID, "github.com/example/deleted-" + deletedRepositoryID.String(), "/tmp/" + deletedRepositoryID.String() + ".git", userID}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{expiredSnapshotID, repositoryID, strings.Repeat("a", 40), "refs/mooncode/snapshots/" + expiredSnapshotID.String()}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{sharedSnapshotID, repositoryID, strings.Repeat("b", 40), "refs/mooncode/snapshots/" + sharedSnapshotID.String()}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{currentSnapshotID, repositoryID, strings.Repeat("c", 40), "refs/mooncode/snapshots/" + currentSnapshotID.String()}},
+ {`INSERT INTO commit_snapshots (id,repository_id,commit_sha,source_ref,git_ref,source_state) VALUES ($1,$2,$3,'main',$4,'available')`, []any{deletedCurrentSnapshotID, deletedRepositoryID, strings.Repeat("d", 40), "refs/mooncode/snapshots/" + deletedCurrentSnapshotID.String()}},
+ {`UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, []any{repositoryID, currentSnapshotID}},
+ {`UPDATE repositories SET current_snapshot_id=$2 WHERE id=$1`, []any{deletedRepositoryID, deletedCurrentSnapshotID}},
+ {`INSERT INTO channels (id,workspace_id,type,name) VALUES ($1,$2,'feishu','Retention notifications')`, []any{channelID, workspaceID}},
+ {`INSERT INTO retention_cleanups (id,workspace_id,scheduled_for,retention_days,status,started_at) VALUES ($1,$2,clock_timestamp(),30,'running',clock_timestamp())`, []any{cleanupID, workspaceID}},
+ }
+ for _, statement := range statements {
+ if _, err := pool.Exec(ctx, statement.query, statement.args...); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ insertRun := func(repositoryID, snapshotID uuid.UUID, status string, finishedAt *time.Time) uuid.UUID {
+ t.Helper()
+ runID := uuid.New()
+ digest := sha256.Sum256(runID[:])
+ stage := "queued"
+ var startedAt any
+ var finished any
+ if finishedAt != nil {
+ stage = "complete"
+ startedAt = finishedAt.Add(-time.Minute)
+ finished = *finishedAt
+ }
+ _, err := pool.Exec(ctx, `
+INSERT INTO analysis_runs (
+ id,workspace_id,repository_id,snapshot_id,commit_sha,requested_by,dimension_key,
+ profile_id,profile_version,profile_snapshot,analyzer_version,idempotency_key,attempt,
+ status,stage,started_at,finished_at
+) VALUES ($1,$2,$3,$4,$5,$6,'code_scale',$7,'v1',$8,'test',$9,1,$10,$11,$12,$13)`,
+ runID, workspaceID, repositoryID, snapshotID, snapshotCommit(snapshotID, map[uuid.UUID]string{
+ expiredSnapshotID: strings.Repeat("a", 40), sharedSnapshotID: strings.Repeat("b", 40),
+ currentSnapshotID: strings.Repeat("c", 40), deletedCurrentSnapshotID: strings.Repeat("d", 40),
+ }), userID, profileID, profileDefinition, hex.EncodeToString(digest[:]), status, stage, startedAt, finished)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ return runID
+ }
+
+ expiredAt := time.Now().Add(-31 * 24 * time.Hour)
+ recentAt := time.Now().Add(-24 * time.Hour)
+ expiredRunID := insertRun(repositoryID, expiredSnapshotID, "succeeded", &expiredAt)
+ deletedRepositoryRunID := insertRun(deletedRepositoryID, deletedCurrentSnapshotID, "succeeded", &expiredAt)
+ recentRunID := insertRun(repositoryID, sharedSnapshotID, "succeeded", &recentAt)
+ queuedRunID := insertRun(repositoryID, sharedSnapshotID, "queued", nil)
+ reportID, notificationID, dispatchID, auditID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `
+INSERT INTO analysis_reports (
+ id,analysis_run_id,workspace_id,repository_id,snapshot_id,commit_sha,source_ref,
+ dimension_key,profile_id,profile_version,profile_snapshot,analyzer_version,
+ execution_environment,started_at,finished_at,duration_ms,result,raw_artifact
+) VALUES ($1,$2,$3,$4,$5,$6,'main','code_scale',$7,'v1',$8,'test','integration',
+ $9,$10,60000,'{}','{}')`, reportID, expiredRunID, workspaceID, repositoryID,
+ expiredSnapshotID, strings.Repeat("a", 40), profileID, profileDefinition, expiredAt.Add(-time.Minute), expiredAt); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `UPDATE analysis_runs SET report_id=$2 WHERE id=$1`, expiredRunID, reportID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO notifications (id,workspace_id,analysis_run_id,channel_id,event_type,status) VALUES ($1,$2,$3,$4,'analysis.succeeded','delivered')`, notificationID, workspaceID, expiredRunID, channelID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workflow_dispatches (id,aggregate_type,aggregate_id,workflow_name,payload,status) VALUES ($1,'analysis_run',$2,'analysis-run','{}','dispatched')`, dispatchID, expiredRunID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO audit_events (id,workspace_id,action,resource_type,resource_id) VALUES ($1,$2,'analysis.created','analysis_run',$3)`, auditID, workspaceID, expiredRunID); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ cleanup := retention.Cleanup{ID: cleanupID, WorkspaceID: workspaceID, RetentionDays: 30, Status: "running"}
+ deleted, err := store.DeleteExpiredRuns(ctx, cleanup, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if deleted != 2 {
+ t.Fatalf("deleted runs = %d, want 2", deleted)
+ }
+ assertRowCount(t, pool, `SELECT count(*) FROM analysis_runs WHERE id = ANY($1)`, []uuid.UUID{expiredRunID, deletedRepositoryRunID}, 0)
+ assertRowCount(t, pool, `SELECT count(*) FROM analysis_runs WHERE id = ANY($1)`, []uuid.UUID{recentRunID, queuedRunID}, 2)
+ assertRowCount(t, pool, `SELECT count(*) FROM analysis_reports WHERE id=$1`, reportID, 0)
+ assertRowCount(t, pool, `SELECT count(*) FROM notifications WHERE id=$1`, notificationID, 0)
+ assertRowCount(t, pool, `SELECT count(*) FROM workflow_dispatches WHERE id=$1`, dispatchID, 1)
+ assertRowCount(t, pool, `SELECT count(*) FROM audit_events WHERE id=$1`, auditID, 1)
+
+ pins, err := store.ClaimSnapshotPins(ctx, cleanup, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(pins) != 1 || pins[0].ID != expiredSnapshotID {
+ t.Fatalf("purgeable pins = %+v, want only expired snapshot", pins)
+ }
+ blockedRunID := uuid.New()
+ blockedDigest := sha256.Sum256(blockedRunID[:])
+ _, err = analysisdata.NewStore(pool).Create(ctx, analysis.Run{
+ ID: blockedRunID, WorkspaceID: workspaceID, RepositoryID: repositoryID,
+ SnapshotID: expiredSnapshotID, CommitSHA: strings.Repeat("a", 40), RequestedBy: userID,
+ DimensionKey: "code_scale", ProfileID: profileID, ProfileVersion: "v1",
+ ProfileSnapshot: profileDefinition, AnalyzerVersion: "test",
+ IdempotencyKey: hex.EncodeToString(blockedDigest[:]), Attempt: 1, Status: "queued", Stage: "queued",
+ })
+ problem, ok := fault.From(err)
+ if !ok || problem.Code() != "analysis.snapshot_unavailable" {
+ t.Fatalf("analysis on purging snapshot error = %v", err)
+ }
+ if err := store.CompleteSnapshotPurge(ctx, cleanup, pins[0]); err != nil {
+ t.Fatal(err)
+ }
+ assertRowCount(t, pool, `SELECT count(*) FROM commit_snapshots WHERE id=$1 AND source_state='purged'`, expiredSnapshotID, 1)
+
+ requeued, err := store.RequeueRepositoryPurges(ctx, cleanup, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if requeued != 1 {
+ t.Fatalf("requeued repositories = %d, want 1", requeued)
+ }
+ assertRowCount(t, pool, `SELECT count(*) FROM repositories WHERE id=$1 AND status='deleting'`, deletedRepositoryID, 1)
+ assertRowCount(t, pool, `SELECT count(*) FROM repository_operations WHERE repository_id=$1 AND kind='purge' AND status='queued'`, deletedRepositoryID, 1)
+ assertRowCount(t, pool, `SELECT count(*) FROM workflow_dispatches WHERE aggregate_type='repository_operation' AND status='pending'`, nil, 1)
+
+ requeued, err = store.RequeueRepositoryPurges(ctx, cleanup, 100)
+ if err != nil || requeued != 0 {
+ t.Fatalf("second requeue = (%d, %v), want idempotent zero", requeued, err)
+ }
+}
+
+func snapshotCommit(id uuid.UUID, commits map[uuid.UUID]string) string {
+ return commits[id]
+}
+
+func assertRowCount(t *testing.T, pool *pgxpool.Pool, query string, argument any, want int) {
+ t.Helper()
+
+ var count int
+ var err error
+ if argument == nil {
+ err = pool.QueryRow(context.Background(), query).Scan(&count)
+ } else {
+ err = pool.QueryRow(context.Background(), query, argument).Scan(&count)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if count != want {
+ t.Fatalf("row count = %d, want %d for %s", count, want, query)
+ }
+}
diff --git a/internal/retention/workflow/options.go b/internal/retention/workflow/options.go
new file mode 100644
index 0000000..2cec4de
--- /dev/null
+++ b/internal/retention/workflow/options.go
@@ -0,0 +1,19 @@
+package workflow
+
+type Metrics interface {
+ AddRetentionProgress(deletedRuns, purgedSnapshots, requeuedRepositories int)
+}
+
+type RunnerOption func(*Runner)
+
+func WithMetrics(metrics Metrics) RunnerOption {
+ return func(runner *Runner) {
+ if metrics != nil {
+ runner.metrics = metrics
+ }
+ }
+}
+
+type discardMetrics struct{}
+
+func (discardMetrics) AddRetentionProgress(int, int, int) {}
diff --git a/internal/retention/workflow/runner.go b/internal/retention/workflow/runner.go
new file mode 100644
index 0000000..b710e8c
--- /dev/null
+++ b/internal/retention/workflow/runner.go
@@ -0,0 +1,111 @@
+package workflow
+
+import (
+ "context"
+ "errors"
+
+ retention "github.com/fuchencong/mooncode/internal/retention/biz"
+ workflowbiz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+type Runner struct {
+ store retention.Store
+ git gitrepo.Manager
+ batchSize int32
+ metrics Metrics
+}
+
+func NewRunner(store retention.Store, git gitrepo.Manager, options ...RunnerOption) *Runner {
+ runner := &Runner{store: store, git: git, batchSize: 100, metrics: discardMetrics{}}
+ for _, option := range options {
+ option(runner)
+ }
+
+ return runner
+}
+
+func (r *Runner) Execute(ctx context.Context, cleanupID uuid.UUID) error {
+ cleanup, err := r.store.GetCleanup(ctx, cleanupID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if cleanup.Status == "succeeded" {
+ return nil
+ }
+ if cleanup.Status == "failed" {
+ return workflowbiz.Permanent(errors.New("retention cleanup is already failed"))
+ }
+
+ cleanup, err = r.store.StartCleanup(ctx, cleanupID)
+ if err != nil {
+ return err
+ }
+ if err = r.deleteExpiredRuns(ctx, cleanup); err != nil {
+ return err
+ }
+ if err = r.releaseSnapshotPins(ctx, cleanup); err != nil {
+ return err
+ }
+ if err = r.requeueRepositoryPurges(ctx, cleanup); err != nil {
+ return err
+ }
+
+ return r.store.FinishCleanup(ctx, cleanup.ID)
+}
+
+func (r *Runner) deleteExpiredRuns(ctx context.Context, cleanup retention.Cleanup) error {
+ for {
+ count, err := r.store.DeleteExpiredRuns(ctx, cleanup, r.batchSize)
+ if err != nil {
+ return err
+ }
+ r.metrics.AddRetentionProgress(count, 0, 0)
+ if count < int(r.batchSize) {
+ return nil
+ }
+ }
+}
+
+func (r *Runner) releaseSnapshotPins(ctx context.Context, cleanup retention.Cleanup) error {
+ for {
+ pins, err := r.store.ClaimSnapshotPins(ctx, cleanup, r.batchSize)
+ if err != nil {
+ return err
+ }
+ for _, pin := range pins {
+ if err = r.git.ReleaseSnapshot(ctx, pin.RepositoryID, pin.ID, pin.CommitSHA); err != nil {
+ return err
+ }
+ if err = r.store.CompleteSnapshotPurge(ctx, cleanup, pin); err != nil {
+ return err
+ }
+ r.metrics.AddRetentionProgress(0, 1, 0)
+ }
+ if len(pins) < int(r.batchSize) {
+ return nil
+ }
+ }
+}
+
+func (r *Runner) requeueRepositoryPurges(ctx context.Context, cleanup retention.Cleanup) error {
+ for {
+ count, err := r.store.RequeueRepositoryPurges(ctx, cleanup, r.batchSize)
+ if err != nil {
+ return err
+ }
+ r.metrics.AddRetentionProgress(0, 0, count)
+ if count < int(r.batchSize) {
+ return nil
+ }
+ }
+}
+
+func (r *Runner) MarkFailed(ctx context.Context, cleanupID uuid.UUID, cause error) error {
+ return r.store.FailCleanup(ctx, cleanupID, cause.Error())
+}
diff --git a/internal/retention/workflow/runner_test.go b/internal/retention/workflow/runner_test.go
new file mode 100644
index 0000000..7dbf4cf
--- /dev/null
+++ b/internal/retention/workflow/runner_test.go
@@ -0,0 +1,194 @@
+package workflow
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ retention "github.com/fuchencong/mooncode/internal/retention/biz"
+ "github.com/fuchencong/mooncode/pkg/gitrepo"
+ "github.com/google/uuid"
+)
+
+type retentionStore struct {
+ cleanup retention.Cleanup
+ deleteBatches []int
+ pinBatches [][]retention.SnapshotPin
+ repositoryBatches []int
+ started bool
+ finished bool
+ failed string
+ completedPins []uuid.UUID
+ scheduledBatches []int
+}
+
+func (s *retentionStore) ScheduleDue(context.Context, int32) (int, error) {
+ value := s.scheduledBatches[0]
+ s.scheduledBatches = s.scheduledBatches[1:]
+ return value, nil
+}
+
+func (s *retentionStore) GetCleanup(context.Context, uuid.UUID) (retention.Cleanup, error) {
+ return s.cleanup, nil
+}
+
+func (s *retentionStore) StartCleanup(context.Context, uuid.UUID) (retention.Cleanup, error) {
+ s.started = true
+ s.cleanup.Status = "running"
+ return s.cleanup, nil
+}
+
+func (s *retentionStore) DeleteExpiredRuns(context.Context, retention.Cleanup, int32) (int, error) {
+ value := s.deleteBatches[0]
+ s.deleteBatches = s.deleteBatches[1:]
+ return value, nil
+}
+
+func (s *retentionStore) ClaimSnapshotPins(context.Context, retention.Cleanup, int32) ([]retention.SnapshotPin, error) {
+ value := s.pinBatches[0]
+ s.pinBatches = s.pinBatches[1:]
+ return value, nil
+}
+
+func (s *retentionStore) CompleteSnapshotPurge(_ context.Context, _ retention.Cleanup, pin retention.SnapshotPin) error {
+ s.completedPins = append(s.completedPins, pin.ID)
+ return nil
+}
+
+func (s *retentionStore) RequeueRepositoryPurges(context.Context, retention.Cleanup, int32) (int, error) {
+ value := s.repositoryBatches[0]
+ s.repositoryBatches = s.repositoryBatches[1:]
+ return value, nil
+}
+
+func (s *retentionStore) FinishCleanup(context.Context, uuid.UUID) error {
+ s.finished = true
+ return nil
+}
+
+func (s *retentionStore) FailCleanup(_ context.Context, _ uuid.UUID, message string) error {
+ s.failed = message
+ return nil
+}
+
+type retentionGit struct {
+ released []uuid.UUID
+ err error
+}
+
+type retentionMetrics struct {
+ deleted int
+ purged int
+ requeued int
+}
+
+func (m *retentionMetrics) AddRetentionProgress(deleted, purged, requeued int) {
+ m.deleted += deleted
+ m.purged += purged
+ m.requeued += requeued
+}
+
+func (*retentionGit) Provision(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) {
+ return gitrepo.Snapshot{}, nil
+}
+
+func (*retentionGit) Sync(context.Context, uuid.UUID, string, string, uuid.UUID, gitrepo.Credential) (gitrepo.Snapshot, error) {
+ return gitrepo.Snapshot{}, nil
+}
+
+func (*retentionGit) Checkout(context.Context, uuid.UUID, string) (string, func() error, error) {
+ return "", func() error { return nil }, nil
+}
+
+func (g *retentionGit) ReleaseSnapshot(_ context.Context, _ uuid.UUID, snapshotID uuid.UUID, _ string) error {
+ if g.err != nil {
+ return g.err
+ }
+ g.released = append(g.released, snapshotID)
+ return nil
+}
+
+func (*retentionGit) Purge(context.Context, uuid.UUID) error { return nil }
+func (*retentionGit) Path(uuid.UUID) string { return "" }
+
+func TestRunnerCleansInBatchesAndRequeuesRepositoryWorkflow(t *testing.T) {
+ cleanup := retention.Cleanup{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "queued", RetentionDays: 90}
+ pins := []retention.SnapshotPin{
+ {ID: uuid.New(), RepositoryID: uuid.New(), CommitSHA: "1111111111111111111111111111111111111111"},
+ {ID: uuid.New(), RepositoryID: uuid.New(), CommitSHA: "2222222222222222222222222222222222222222"},
+ }
+ store := &retentionStore{
+ cleanup: cleanup, deleteBatches: []int{2, 1}, pinBatches: [][]retention.SnapshotPin{pins, {}},
+ repositoryBatches: []int{2, 1},
+ }
+ git := &retentionGit{}
+ metrics := &retentionMetrics{}
+ runner := NewRunner(store, git, WithMetrics(metrics))
+ runner.batchSize = 2
+
+ if err := runner.Execute(context.Background(), cleanup.ID); err != nil {
+ t.Fatal(err)
+ }
+ if !store.started || !store.finished || store.failed != "" {
+ t.Fatalf("unexpected cleanup lifecycle: started=%v finished=%v failed=%q", store.started, store.finished, store.failed)
+ }
+ if len(git.released) != len(pins) || len(store.completedPins) != len(pins) {
+ t.Fatalf("snapshot releases = %v, completed = %v", git.released, store.completedPins)
+ }
+ if len(store.deleteBatches) != 0 || len(store.pinBatches) != 0 || len(store.repositoryBatches) != 0 {
+ t.Fatal("runner did not drain all cleanup batches")
+ }
+ if metrics.deleted != 3 || metrics.purged != 2 || metrics.requeued != 3 {
+ t.Fatalf("retention metrics = %+v", metrics)
+ }
+}
+
+func TestRunnerDoesNotFinishWhenSnapshotReleaseFails(t *testing.T) {
+ cleanup := retention.Cleanup{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "queued", RetentionDays: 90}
+ pin := retention.SnapshotPin{ID: uuid.New(), RepositoryID: uuid.New(), CommitSHA: "1111111111111111111111111111111111111111"}
+ store := &retentionStore{
+ cleanup: cleanup, deleteBatches: []int{0}, pinBatches: [][]retention.SnapshotPin{{pin}},
+ repositoryBatches: []int{0},
+ }
+ want := errors.New("git unavailable")
+ runner := NewRunner(store, &retentionGit{err: want})
+
+ err := runner.Execute(context.Background(), cleanup.ID)
+ if !errors.Is(err, want) {
+ t.Fatalf("Execute() error = %v, want %v", err, want)
+ }
+ if store.finished || len(store.completedPins) != 0 {
+ t.Fatal("failed Git release was projected as complete")
+ }
+ if err := runner.MarkFailed(context.Background(), cleanup.ID, want); err != nil {
+ t.Fatal(err)
+ }
+ if store.failed != want.Error() {
+ t.Fatalf("failure = %q, want %q", store.failed, want)
+ }
+}
+
+func TestRunnerTreatsSucceededCleanupAsIdempotent(t *testing.T) {
+ cleanup := retention.Cleanup{ID: uuid.New(), WorkspaceID: uuid.New(), Status: "succeeded"}
+ store := &retentionStore{cleanup: cleanup}
+
+ if err := NewRunner(store, &retentionGit{}).Execute(context.Background(), cleanup.ID); err != nil {
+ t.Fatal(err)
+ }
+ if store.started || store.finished {
+ t.Fatal("succeeded cleanup was executed again")
+ }
+}
+
+func TestSchedulerDrainsDueWorkspaces(t *testing.T) {
+ store := &retentionStore{scheduledBatches: []int{2, 1}}
+ scheduler := NewScheduler(store)
+ scheduler.batchSize = 2
+
+ if err := scheduler.Execute(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.scheduledBatches) != 0 {
+ t.Fatal("scheduler did not drain due workspace batches")
+ }
+}
diff --git a/internal/retention/workflow/scheduler.go b/internal/retention/workflow/scheduler.go
new file mode 100644
index 0000000..734c21d
--- /dev/null
+++ b/internal/retention/workflow/scheduler.go
@@ -0,0 +1,28 @@
+package workflow
+
+import (
+ "context"
+
+ retention "github.com/fuchencong/mooncode/internal/retention/biz"
+)
+
+type Scheduler struct {
+ store retention.Store
+ batchSize int32
+}
+
+func NewScheduler(store retention.Store) *Scheduler {
+ return &Scheduler{store: store, batchSize: 100}
+}
+
+func (s *Scheduler) Execute(ctx context.Context) error {
+ for {
+ count, err := s.store.ScheduleDue(ctx, s.batchSize)
+ if err != nil {
+ return err
+ }
+ if count < int(s.batchSize) {
+ return nil
+ }
+ }
+}
diff --git a/internal/sanitize/error.go b/internal/sanitize/error.go
deleted file mode 100644
index a9dd55d..0000000
--- a/internal/sanitize/error.go
+++ /dev/null
@@ -1,34 +0,0 @@
-// Package sanitize removes credentials and unbounded data from persisted errors.
-package sanitize
-
-import (
- "regexp"
- "strings"
- "unicode/utf8"
-)
-
-var (
- urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`)
- secretPattern = regexp.MustCompile(`(?i)(authorization|cookie|token|secret|password|signature|webhook)[=:][^\s,;]+`)
-)
-
-func ErrorMessage(err error, maxBytes int) string {
- if err == nil || maxBytes <= 0 {
- return ""
- }
- message := urlPattern.ReplaceAllString(err.Error(), "[redacted-url]")
- message = secretPattern.ReplaceAllStringFunc(message, func(value string) string {
- if index := strings.IndexAny(value, "=:"); index >= 0 {
- return value[:index+1] + "[REDACTED]"
- }
- return "[REDACTED]"
- })
- if len(message) <= maxBytes {
- return message
- }
- message = message[:maxBytes]
- for !utf8.ValidString(message) {
- message = message[:len(message)-1]
- }
- return message
-}
diff --git a/internal/sanitize/error_test.go b/internal/sanitize/error_test.go
deleted file mode 100644
index 0cb39dd..0000000
--- a/internal/sanitize/error_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package sanitize
-
-import (
- "errors"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestErrorMessageRedactsURLsSecretsAndUTF8Safely(t *testing.T) {
- message := ErrorMessage(errors.New("connect https://example.test/hook/private?token=abc token=secret 密钥"), 60)
- require.NotContains(t, message, "private")
- require.NotContains(t, message, "abc")
- require.NotContains(t, message, "secret")
- require.LessOrEqual(t, len(message), 60)
-}
diff --git a/internal/secretstore/encrypted.go b/internal/secretstore/encrypted.go
deleted file mode 100644
index 19e9864..0000000
--- a/internal/secretstore/encrypted.go
+++ /dev/null
@@ -1,237 +0,0 @@
-package secretstore
-
-import (
- "context"
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "sort"
- "strings"
-
- "github.com/google/uuid"
-)
-
-type Option func(*options) error
-
-type options struct {
- currentVersion int32
- masterKeys map[int32][]byte
- random io.Reader
-}
-
-func WithMasterKey(version int32, key []byte) Option {
- return func(cfg *options) error {
- if version <= 0 {
- return errors.New("secretstore master key version must be positive")
- }
- if len(key) != 32 {
- return errors.New("secretstore master key must be 32 bytes")
- }
- cfg.masterKeys[version] = append([]byte(nil), key...)
- if version > cfg.currentVersion {
- cfg.currentVersion = version
- }
- return nil
- }
-}
-
-func WithCurrentKeyVersion(version int32) Option {
- return func(cfg *options) error {
- if version <= 0 {
- return errors.New("secretstore current key version must be positive")
- }
- cfg.currentVersion = version
- return nil
- }
-}
-
-func WithRandom(source io.Reader) Option {
- return func(cfg *options) error {
- if source == nil {
- return errors.New("secretstore random source cannot be nil")
- }
- cfg.random = source
- return nil
- }
-}
-
-type EncryptedStore struct {
- records RecordStore
- currentVersion int32
- masterKeys map[int32][]byte
- random io.Reader
-}
-
-func NewEncryptedStore(records RecordStore, opts ...Option) (*EncryptedStore, error) {
- if records == nil {
- return nil, errors.New("secretstore record store is required")
- }
- cfg := options{masterKeys: make(map[int32][]byte), random: rand.Reader}
- for _, option := range opts {
- if option != nil {
- if err := option(&cfg); err != nil {
- return nil, err
- }
- }
- }
- if _, ok := cfg.masterKeys[cfg.currentVersion]; !ok {
- return nil, fmt.Errorf("secretstore current master key version %d is not configured", cfg.currentVersion)
- }
- return &EncryptedStore{records: records, currentVersion: cfg.currentVersion, masterKeys: cfg.masterKeys, random: cfg.random}, nil
-}
-
-func (s *EncryptedStore) Put(ctx context.Context, scope Scope, values SecretValues) (SecretRef, error) {
- if err := scope.Validate(); err != nil {
- return SecretRef{}, err
- }
- plaintext, err := marshalValues(values)
- if err != nil {
- return SecretRef{}, err
- }
- dataKey := make([]byte, 32)
- if _, err := io.ReadFull(s.random, dataKey); err != nil {
- return SecretRef{}, fmt.Errorf("generate secret data key: %w", err)
- }
- defer clear(dataKey)
- aad := associatedData(scope)
- ciphertext, nonce, err := seal(dataKey, plaintext, aad, s.random)
- clear(plaintext)
- if err != nil {
- return SecretRef{}, err
- }
- masterKey := s.masterKeys[s.currentVersion]
- wrappedKey, wrappedNonce, err := seal(masterKey, dataKey, aad, s.random)
- if err != nil {
- return SecretRef{}, err
- }
- id, err := uuid.NewV7()
- if err != nil {
- return SecretRef{}, fmt.Errorf("generate secret ID: %w", err)
- }
- record := Record{ID: id, Scope: scope, Ciphertext: ciphertext, Nonce: nonce, WrappedKey: wrappedKey, WrappedKeyNonce: wrappedNonce, KeyVersion: s.currentVersion}
- if err := s.records.CreateSecretRecord(ctx, record); err != nil {
- return SecretRef{}, fmt.Errorf("persist encrypted secret: %w", err)
- }
- return SecretRef{ID: id, Scope: scope}, nil
-}
-
-func (s *EncryptedStore) Get(ctx context.Context, ref SecretRef) (SecretValues, error) {
- if err := validateRef(ref); err != nil {
- return nil, err
- }
- record, err := s.records.GetSecretRecord(ctx, ref.ID, ref.Scope.WorkspaceID)
- if err != nil {
- return nil, err
- }
- if record.Scope != ref.Scope {
- return nil, ErrDecrypt
- }
- masterKey, ok := s.masterKeys[record.KeyVersion]
- if !ok {
- return nil, fmt.Errorf("%w: unknown key version", ErrDecrypt)
- }
- aad := associatedData(ref.Scope)
- dataKey, err := open(masterKey, record.WrappedKeyNonce, record.WrappedKey, aad)
- if err != nil {
- return nil, ErrDecrypt
- }
- defer clear(dataKey)
- plaintext, err := open(dataKey, record.Nonce, record.Ciphertext, aad)
- if err != nil {
- return nil, ErrDecrypt
- }
- defer clear(plaintext)
- values, err := unmarshalValues(plaintext)
- if err != nil {
- return nil, ErrDecrypt
- }
- return values, nil
-}
-
-func (s *EncryptedStore) Delete(ctx context.Context, ref SecretRef) error {
- if err := validateRef(ref); err != nil {
- return err
- }
- return s.records.DeleteSecretRecord(ctx, ref.ID, ref.Scope.WorkspaceID)
-}
-
-func seal(key, plaintext, aad []byte, random io.Reader) ([]byte, []byte, error) {
- aead, err := newGCM(key)
- if err != nil {
- return nil, nil, err
- }
- nonce := make([]byte, aead.NonceSize())
- if _, err := io.ReadFull(random, nonce); err != nil {
- return nil, nil, fmt.Errorf("generate encryption nonce: %w", err)
- }
- return aead.Seal(nil, nonce, plaintext, aad), nonce, nil
-}
-
-func open(key, nonce, ciphertext, aad []byte) ([]byte, error) {
- aead, err := newGCM(key)
- if err != nil {
- return nil, err
- }
- return aead.Open(nil, nonce, ciphertext, aad)
-}
-
-func newGCM(key []byte) (cipher.AEAD, error) {
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("create AES cipher: %w", err)
- }
- aead, err := cipher.NewGCM(block)
- if err != nil {
- return nil, fmt.Errorf("create AES-GCM: %w", err)
- }
- return aead, nil
-}
-
-func associatedData(scope Scope) []byte {
- return []byte(scope.WorkspaceID.String() + "\x00" + scope.ResourceType + "\x00" + scope.ResourceID.String())
-}
-
-func marshalValues(values SecretValues) ([]byte, error) {
- if len(values) == 0 {
- return nil, errors.New("secret values cannot be empty")
- }
- plain := make(map[string]string, len(values))
- keys := make([]string, 0, len(values))
- for key, value := range values {
- key = strings.TrimSpace(key)
- if key == "" {
- return nil, errors.New("secret field name cannot be empty")
- }
- keys = append(keys, key)
- plain[key] = value.Reveal()
- }
- // Deterministic key order makes tests and forensic checks reproducible; the
- // random nonce still guarantees probabilistic encryption.
- sort.Strings(keys)
- ordered := make([]struct{ Key, Value string }, 0, len(keys))
- for _, key := range keys {
- ordered = append(ordered, struct{ Key, Value string }{key, plain[key]})
- }
- return json.Marshal(ordered)
-}
-
-func unmarshalValues(data []byte) (SecretValues, error) {
- var ordered []struct{ Key, Value string }
- if err := json.Unmarshal(data, &ordered); err != nil {
- return nil, err
- }
- values := make(SecretValues, len(ordered))
- for _, item := range ordered {
- if item.Key == "" {
- return nil, errors.New("empty secret field")
- }
- values[item.Key] = NewSecretString(item.Value)
- }
- return values, nil
-}
-
-var _ SecretStore = (*EncryptedStore)(nil)
diff --git a/internal/secretstore/encrypted_test.go b/internal/secretstore/encrypted_test.go
deleted file mode 100644
index 96290b0..0000000
--- a/internal/secretstore/encrypted_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package secretstore
-
-import (
- "context"
- "fmt"
- "sync"
- "testing"
-
- "github.com/google/uuid"
- "github.com/stretchr/testify/require"
-)
-
-type memoryRecords struct {
- mu sync.Mutex
- records map[uuid.UUID]Record
-}
-
-func newMemoryRecords() *memoryRecords { return &memoryRecords{records: make(map[uuid.UUID]Record)} }
-func (m *memoryRecords) CreateSecretRecord(_ context.Context, record Record) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.records[record.ID] = record
- return nil
-}
-func (m *memoryRecords) GetSecretRecord(_ context.Context, id, workspaceID uuid.UUID) (Record, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- record, ok := m.records[id]
- if !ok || record.Scope.WorkspaceID != workspaceID {
- return Record{}, ErrNotFound
- }
- return record, nil
-}
-func (m *memoryRecords) DeleteSecretRecord(_ context.Context, id, workspaceID uuid.UUID) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- record, ok := m.records[id]
- if ok && record.Scope.WorkspaceID == workspaceID {
- delete(m.records, id)
- }
- return nil
-}
-
-func TestEncryptedStoreRoundTripAndRedaction(t *testing.T) {
- records := newMemoryRecords()
- store, err := NewEncryptedStore(records, WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef")))
- require.NoError(t, err)
- scope := Scope{WorkspaceID: uuid.Must(uuid.NewV7()), ResourceType: "repository", ResourceID: uuid.Must(uuid.NewV7())}
- values := SecretValues{"token": NewSecretString("super-secret"), "username": NewSecretString("git")}
- ref, err := store.Put(context.Background(), scope, values)
- require.NoError(t, err)
- require.NotContains(t, string(records.records[ref.ID].Ciphertext), "super-secret")
- loaded, err := store.Get(context.Background(), ref)
- require.NoError(t, err)
- require.Equal(t, "super-secret", loaded["token"].Reveal())
- require.Equal(t, "[REDACTED]", fmt.Sprint(loaded["token"]))
-}
-
-func TestEncryptedStoreRejectsScopeSubstitution(t *testing.T) {
- store, err := NewEncryptedStore(newMemoryRecords(), WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef")))
- require.NoError(t, err)
- scope := Scope{WorkspaceID: uuid.Must(uuid.NewV7()), ResourceType: "channel", ResourceID: uuid.Must(uuid.NewV7())}
- ref, err := store.Put(context.Background(), scope, SecretValues{"token": NewSecretString("secret")})
- require.NoError(t, err)
- ref.Scope.ResourceID = uuid.Must(uuid.NewV7())
- _, err = store.Get(context.Background(), ref)
- require.ErrorIs(t, err, ErrDecrypt)
-}
-
-func TestEncryptedStoreSupportsOldKeyVersion(t *testing.T) {
- records := newMemoryRecords()
- oldStore, err := NewEncryptedStore(records, WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef")))
- require.NoError(t, err)
- scope := Scope{WorkspaceID: uuid.Must(uuid.NewV7()), ResourceType: "scm", ResourceID: uuid.Must(uuid.NewV7())}
- ref, err := oldStore.Put(context.Background(), scope, SecretValues{"token": NewSecretString("old")})
- require.NoError(t, err)
- rotated, err := NewEncryptedStore(records,
- WithMasterKey(1, []byte("0123456789abcdef0123456789abcdef")),
- WithMasterKey(2, []byte("abcdef0123456789abcdef0123456789")),
- WithCurrentKeyVersion(2),
- )
- require.NoError(t, err)
- values, err := rotated.Get(context.Background(), ref)
- require.NoError(t, err)
- require.Equal(t, "old", values["token"].Reveal())
-}
diff --git a/internal/secretstore/secretstore.go b/internal/secretstore/secretstore.go
deleted file mode 100644
index cab0ab0..0000000
--- a/internal/secretstore/secretstore.go
+++ /dev/null
@@ -1,81 +0,0 @@
-// Package secretstore provides workspace-scoped application secret storage.
-// Secret plaintext is envelope-encrypted before it reaches the persistence
-// adapter.
-package secretstore
-
-import (
- "context"
- "errors"
- "fmt"
-
- "github.com/google/uuid"
-)
-
-var (
- ErrNotFound = errors.New("secretstore: not found")
- ErrInvalidScope = errors.New("secretstore: invalid scope")
- ErrDecrypt = errors.New("secretstore: decrypt failed")
-)
-
-// Scope binds a secret to exactly one MoonCode resource.
-type Scope struct {
- WorkspaceID uuid.UUID
- ResourceType string
- ResourceID uuid.UUID
-}
-
-func (s Scope) Validate() error {
- if s.WorkspaceID == uuid.Nil || s.ResourceID == uuid.Nil || s.ResourceType == "" {
- return ErrInvalidScope
- }
- return nil
-}
-
-// SecretRef is safe to persist in ordinary resource rows. It contains no
-// plaintext and carries the scope required to authenticate decryption.
-type SecretRef struct {
- ID uuid.UUID `json:"id"`
- Scope Scope `json:"-"`
-}
-
-// SecretString prevents common formatting paths from exposing plaintext.
-type SecretString struct{ value string }
-
-func NewSecretString(value string) SecretString { return SecretString{value: value} }
-func (s SecretString) Reveal() string { return s.value }
-func (SecretString) String() string { return "[REDACTED]" }
-func (SecretString) GoString() string { return "secretstore.SecretString{[REDACTED]}" }
-
-// SecretValues is a named set of secret fields such as token or app_secret.
-type SecretValues map[string]SecretString
-
-type SecretStore interface {
- Put(ctx context.Context, scope Scope, values SecretValues) (SecretRef, error)
- Get(ctx context.Context, ref SecretRef) (SecretValues, error)
- Delete(ctx context.Context, ref SecretRef) error
-}
-
-// Record is the encrypted persistence representation. Plaintext must never be
-// added to this type.
-type Record struct {
- ID uuid.UUID
- Scope Scope
- Ciphertext []byte
- Nonce []byte
- WrappedKey []byte
- WrappedKeyNonce []byte
- KeyVersion int32
-}
-
-type RecordStore interface {
- CreateSecretRecord(ctx context.Context, record Record) error
- GetSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) (Record, error)
- DeleteSecretRecord(ctx context.Context, id, workspaceID uuid.UUID) error
-}
-
-func validateRef(ref SecretRef) error {
- if ref.ID == uuid.Nil {
- return fmt.Errorf("%w: secret ID is required", ErrInvalidScope)
- }
- return ref.Scope.Validate()
-}
diff --git a/internal/service/audit.go b/internal/service/audit.go
deleted file mode 100644
index 617308a..0000000
--- a/internal/service/audit.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package service
-
-import (
- "context"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-type AuditService struct {
- store repository.MetadataStore
- identities *IdentityService
-}
-
-type AuditServiceOption func(*AuditService)
-
-type AuditPage struct {
- Items []model.AuditLog `json:"items"`
- NextCursor string `json:"nextCursor,omitempty"`
-}
-
-func NewAuditService(store repository.MetadataStore, identities *IdentityService, opts ...AuditServiceOption) *AuditService {
- service := &AuditService{store: store, identities: identities}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-
-func (s *AuditService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, cursor string, pageSize int32) (AuditPage, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil {
- return AuditPage{}, err
- }
- if pageSize <= 0 || pageSize > 100 {
- pageSize = 50
- }
- decoded, err := decodeTimeCursor(cursor)
- if err != nil {
- return AuditPage{}, err
- }
- items, err := s.store.ListAuditLogs(ctx, workspaceID, decoded, pageSize+1)
- if err != nil {
- return AuditPage{}, err
- }
- page := AuditPage{Items: items}
- if len(items) > int(pageSize) {
- page.Items = items[:pageSize]
- last := page.Items[len(page.Items)-1]
- page.NextCursor = encodeTimeCursor(last.OccurredAt, last.ID)
- }
- return page, nil
-}
diff --git a/internal/service/audit_failure.go b/internal/service/audit_failure.go
deleted file mode 100644
index 4e55ac9..0000000
--- a/internal/service/audit_failure.go
+++ /dev/null
@@ -1,21 +0,0 @@
-package service
-
-import (
- "context"
- "time"
-
- "github.com/google/uuid"
-)
-
-type failureAuditWriter interface {
- AppendAuditResult(context.Context, uuid.UUID, uuid.UUID, string, string, uuid.UUID, string, []byte) error
-}
-
-func recordFailure(ctx context.Context, writer failureAuditWriter, workspaceID, actorID uuid.UUID, action, resourceType string, resourceID uuid.UUID, resultErr error) {
- if resultErr == nil || writer == nil || workspaceID == uuid.Nil || actorID == uuid.Nil {
- return
- }
- auditCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
- defer cancel()
- _ = writer.AppendAuditResult(auditCtx, workspaceID, actorID, action, resourceType, resourceID, "failure", []byte(`{"code":"operation_failed"}`))
-}
diff --git a/internal/service/audit_failure_test.go b/internal/service/audit_failure_test.go
deleted file mode 100644
index e40fb75..0000000
--- a/internal/service/audit_failure_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "sync"
- "testing"
-
- "github.com/google/uuid"
- "github.com/stretchr/testify/require"
-)
-
-type recordedFailureAudit struct {
- mu sync.Mutex
- called int
- ctxErr error
- resourceID uuid.UUID
- result string
-}
-
-func (w *recordedFailureAudit) AppendAuditResult(ctx context.Context, _, _ uuid.UUID, _, _ string, resourceID uuid.UUID, result string, _ []byte) error {
- w.mu.Lock()
- defer w.mu.Unlock()
- w.called++
- w.ctxErr = ctx.Err()
- w.resourceID = resourceID
- w.result = result
- return nil
-}
-
-func TestRecordFailureUsesDetachedBoundedContext(t *testing.T) {
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- writer := &recordedFailureAudit{}
-
- recordFailure(ctx, writer, uuid.New(), uuid.New(), "repository.create", "repository", uuid.Nil, errors.New("failed"))
-
- require.Equal(t, 1, writer.called)
- require.NoError(t, writer.ctxErr)
- require.Equal(t, uuid.Nil, writer.resourceID)
- require.Equal(t, "failure", writer.result)
-}
-
-func TestRecordFailureSkipsSuccessfulOrUnscopedOperations(t *testing.T) {
- writer := &recordedFailureAudit{}
- recordFailure(context.Background(), writer, uuid.New(), uuid.New(), "test", "resource", uuid.New(), nil)
- recordFailure(context.Background(), writer, uuid.Nil, uuid.New(), "test", "resource", uuid.New(), errors.New("failed"))
- recordFailure(context.Background(), writer, uuid.New(), uuid.Nil, "test", "resource", uuid.New(), errors.New("failed"))
- require.Zero(t, writer.called)
-}
diff --git a/internal/service/authorization_test.go b/internal/service/authorization_test.go
deleted file mode 100644
index b89f15c..0000000
--- a/internal/service/authorization_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package service
-
-import (
- "fmt"
- "testing"
-
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/repository/memory"
- "github.com/stretchr/testify/require"
-)
-
-func TestWorkspaceAuthorizationRoleMatrixAndTenantIsolation(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- workspaces := NewWorkspaceService(store, identities)
- principals := make(map[string]model.Principal)
- for _, role := range []string{"owner", "admin", "member", "viewer", "outsider"} {
- principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: "test", Subject: role, Username: role, Email: role + "@example.com"})
- require.NoError(t, err)
- session, err := registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- principals[role] = model.Principal{User: session.User}
- }
- workspace, err := workspaces.Create(t.Context(), principals["owner"], "role matrix")
- require.NoError(t, err)
- for _, role := range []string{"admin", "member", "viewer"} {
- principals[role] = inviteTestMember(t, registrations, principals["owner"], principals[role], workspace.ID, role)
- }
-
- tests := []struct {
- name string
- allowed []string
- roles []string
- }{
- {name: "read", allowed: []string{"owner", "admin", "member", "viewer"}},
- {name: "mutate resource", roles: []string{"owner", "admin", "member"}, allowed: []string{"owner", "admin", "member"}},
- {name: "administer workspace", roles: []string{"owner", "admin"}, allowed: []string{"owner", "admin"}},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- for _, role := range []string{"owner", "admin", "member", "viewer", "outsider"} {
- _, authErr := identities.AuthorizeWorkspace(t.Context(), principals[role], workspace.ID, test.roles...)
- expected := containsRole(test.allowed, role)
- if expected {
- require.NoError(t, authErr, role)
- } else {
- require.ErrorIs(t, authErr, repository.ErrNotFound, fmt.Sprintf("role %s must not discover the resource", role))
- }
- }
- })
- }
-}
-
-func containsRole(values []string, target string) bool {
- for _, value := range values {
- if value == target {
- return true
- }
- }
- return false
-}
diff --git a/internal/service/channel.go b/internal/service/channel.go
deleted file mode 100644
index cd8cd1b..0000000
--- a/internal/service/channel.go
+++ /dev/null
@@ -1,367 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
- channelcore "github.com/mooncode-ai/mooncode/pkg/channel"
-)
-
-type CreateChannelInput struct {
- Type string
- Name string
- Values map[string]any
- Secrets map[string]string
- SenderAllowList []string
- GroupPolicy channelcore.GroupPolicy
- IdempotencyKey string
-}
-type UpdateChannelInput struct {
- Version int64
- Name *string
- Values map[string]any
- Secrets map[string]string
- ClearSecretFields []string
- SenderAllowList *[]string
- GroupPolicy *channelcore.GroupPolicy
- IdempotencyKey string
-}
-type ChannelService struct {
- store repository.ChannelStore
- secrets secretstore.SecretStore
- registry *channelcore.Registry
- identities *IdentityService
- clock func() time.Time
- cfg config.ChannelsConfig
-}
-type ChannelServiceOption func(*ChannelService)
-
-func WithChannelServiceClock(clock func() time.Time) ChannelServiceOption {
- return func(service *ChannelService) {
- if clock != nil {
- service.clock = clock
- }
- }
-}
-
-func NewChannelService(store repository.ChannelStore, secrets secretstore.SecretStore, registry *channelcore.Registry, identities *IdentityService, cfg config.Config, opts ...ChannelServiceOption) *ChannelService {
- service := &ChannelService{store: store, secrets: secrets, registry: registry, identities: identities, clock: time.Now, cfg: cfg.Channels}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-func (s *ChannelService) Descriptors() []channelcore.Descriptor { return s.registry.Descriptors() }
-
-func (s *ChannelService) Create(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateChannelInput) (instance model.ChannelInstance, resultErr error) {
- resourceID := uuid.Nil
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, "channel.create", "channel_instance", resourceID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.ChannelInstance{}, err
- }
- input.Type, input.Name = strings.ToLower(strings.TrimSpace(input.Type)), strings.TrimSpace(input.Name)
- if input.Name == "" {
- return model.ChannelInstance{}, errors.New("channel name is required")
- }
- factory, ok := s.registry.Factory(input.Type)
- if !ok {
- return model.ChannelInstance{}, errors.New("unknown channel type")
- }
- id, err := uuid.NewV7()
- if err != nil {
- return model.ChannelInstance{}, err
- }
- resourceID = id
- instanceConfig := channelcore.InstanceConfig{AccountID: id.String(), Values: input.Values, Secrets: input.Secrets, SenderAllowList: input.SenderAllowList, GroupPolicy: input.GroupPolicy}
- if err := factory.Validate(instanceConfig); err != nil {
- return model.ChannelInstance{}, err
- }
- instance = model.ChannelInstance{ID: id, WorkspaceID: workspaceID, Type: input.Type, Name: input.Name, Config: model.ChannelConfig{Values: cloneValues(input.Values), SenderAllowList: append([]string(nil), input.SenderAllowList...), GroupPolicy: input.GroupPolicy}}
- mutation, err := newMutationRequest(workspaceID, "channel.create", "name:"+strings.ToLower(input.Name), input.IdempotencyKey, input, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC())
- if err != nil {
- return model.ChannelInstance{}, err
- }
- claim, err := s.store.BeginMutation(ctx, mutation)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- if claim.Replay {
- return s.store.GetChannel(ctx, workspaceID, claim.ResourceID)
- }
- succeeded := false
- defer func() { finishMutation(s.store, mutation, succeeded) }()
- var ref secretstore.SecretRef
- if len(input.Secrets) > 0 {
- ref, err = s.secrets.Put(ctx, secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}, secretValues(input.Secrets))
- if err != nil {
- return model.ChannelInstance{}, err
- }
- instance.SecretRef = &ref.ID
- }
- err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error {
- var txErr error
- instance, txErr = tx.CreateChannel(ctx, instance)
- if txErr != nil {
- return txErr
- }
- metadata, _ := json.Marshal(map[string]string{"type": input.Type})
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "channel.create", "channel_instance", id, metadata)
- })
- if err != nil && ref.ID != uuid.Nil {
- _ = s.secrets.Delete(ctx, ref)
- }
- succeeded = err == nil
- return instance, err
-}
-
-func (s *ChannelService) Update(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, input UpdateChannelInput) (instance model.ChannelInstance, resultErr error) {
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, "channel.update", "channel_instance", id, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.ChannelInstance{}, err
- }
- instance, err := s.store.GetChannel(ctx, workspaceID, id)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- if input.Version != instance.ConfigVersion {
- return model.ChannelInstance{}, repository.ErrConflict
- }
- if input.Name != nil {
- instance.Name = strings.TrimSpace(*input.Name)
- if instance.Name == "" {
- return model.ChannelInstance{}, errors.New("channel name is required")
- }
- }
- if input.Values != nil {
- instance.Config.Values = cloneValues(input.Values)
- }
- if input.SenderAllowList != nil {
- instance.Config.SenderAllowList = append([]string(nil), (*input.SenderAllowList)...)
- }
- if input.GroupPolicy != nil {
- instance.Config.GroupPolicy = *input.GroupPolicy
- }
- currentSecrets := map[string]string{}
- oldRef := secretstore.SecretRef{}
- if instance.SecretRef != nil {
- oldRef = secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}}
- values, getErr := s.secrets.Get(ctx, oldRef)
- if getErr != nil {
- return model.ChannelInstance{}, getErr
- }
- for key, value := range values {
- currentSecrets[key] = value.Reveal()
- }
- }
- for _, key := range input.ClearSecretFields {
- delete(currentSecrets, key)
- }
- for key, value := range input.Secrets {
- currentSecrets[key] = value
- }
- factory, _ := s.registry.Factory(instance.Type)
- if err := factory.Validate(channelcore.InstanceConfig{AccountID: id.String(), Values: instance.Config.Values, Secrets: currentSecrets, SenderAllowList: instance.Config.SenderAllowList, GroupPolicy: instance.Config.GroupPolicy}); err != nil {
- return model.ChannelInstance{}, err
- }
- mutation, err := newMutationRequest(workspaceID, "channel.update", id.String(), input.IdempotencyKey, input, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC())
- if err != nil {
- return model.ChannelInstance{}, err
- }
- claim, err := s.store.BeginMutation(ctx, mutation)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- if claim.Replay {
- return s.store.GetChannel(ctx, workspaceID, id)
- }
- succeeded := false
- defer func() { finishMutation(s.store, mutation, succeeded) }()
- newRef := secretstore.SecretRef{}
- if len(input.Secrets) > 0 || len(input.ClearSecretFields) > 0 {
- if len(currentSecrets) > 0 {
- scope := oldRef.Scope
- if scope.ResourceID == uuid.Nil {
- scope = secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}
- }
- newRef, err = s.secrets.Put(ctx, scope, secretValues(currentSecrets))
- if err != nil {
- return model.ChannelInstance{}, err
- }
- instance.SecretRef = &newRef.ID
- } else {
- instance.SecretRef = nil
- }
- }
- err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error {
- var txErr error
- instance, txErr = tx.UpdateChannel(ctx, instance, input.Version)
- if txErr != nil {
- return txErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "channel.update", "channel_instance", id, []byte(`{}`))
- })
- if err != nil {
- if newRef.ID != uuid.Nil {
- _ = s.secrets.Delete(ctx, newRef)
- }
- return model.ChannelInstance{}, err
- }
- if newRef.ID != uuid.Nil && oldRef.ID != uuid.Nil {
- _ = s.secrets.Delete(ctx, oldRef)
- }
- succeeded = true
- return instance, nil
-}
-
-func (s *ChannelService) SetEnabled(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, enabled bool, idempotencyKey string) (instance model.ChannelInstance, resultErr error) {
- action := "channel.disable"
- if enabled {
- action = "channel.enable"
- }
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, action, "channel_instance", id, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.ChannelInstance{}, err
- }
- instance, err := s.store.GetChannel(ctx, workspaceID, id)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- if enabled {
- config, err := s.runtimeConfig(ctx, instance)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- factory, ok := s.registry.Factory(instance.Type)
- if !ok {
- return model.ChannelInstance{}, errors.New("unknown channel type")
- }
- if err := factory.Validate(config); err != nil {
- return model.ChannelInstance{}, err
- }
- }
- mutation, err := newMutationRequest(workspaceID, action, id.String(), idempotencyKey, map[string]bool{"enabled": enabled}, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC())
- if err != nil {
- return model.ChannelInstance{}, err
- }
- claim, err := s.store.BeginMutation(ctx, mutation)
- if err != nil {
- return model.ChannelInstance{}, err
- }
- if claim.Replay {
- return s.store.GetChannel(ctx, workspaceID, id)
- }
- succeeded := false
- defer func() { finishMutation(s.store, mutation, succeeded) }()
- err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error {
- var txErr error
- instance, txErr = tx.SetChannelEnabled(ctx, workspaceID, id, enabled)
- if txErr != nil {
- return txErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, action, "channel_instance", id, []byte(`{}`))
- })
- succeeded = err == nil
- return instance, err
-}
-func (s *ChannelService) Delete(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, idempotencyKey string) (resultErr error) {
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, "channel.delete", "channel_instance", id, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil {
- return err
- }
- mutation, err := newMutationRequest(workspaceID, "channel.delete", id.String(), idempotencyKey, map[string]string{"channelId": id.String()}, id, s.cfg.MutationTimeout, s.cfg.MutationCooldown, s.clock().UTC())
- if err != nil {
- return err
- }
- claim, err := s.store.BeginMutation(ctx, mutation)
- if err != nil {
- return err
- }
- if claim.Replay {
- return nil
- }
- succeeded := false
- defer func() { finishMutation(s.store, mutation, succeeded) }()
- instance, err := s.store.GetChannel(ctx, workspaceID, id)
- if err != nil {
- return err
- }
- err = s.store.WithinChannelTx(ctx, func(tx repository.ChannelMutationStore) error {
- if _, txErr := tx.SoftDeleteChannel(ctx, workspaceID, id); txErr != nil {
- return txErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "channel.delete", "channel_instance", id, []byte(`{}`))
- })
- if err != nil {
- return err
- }
- succeeded = true
- if instance.SecretRef != nil {
- _ = s.secrets.Delete(ctx, secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "channel_instance", ResourceID: id}})
- }
- return nil
-}
-func (s *ChannelService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.ChannelInstance, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return nil, err
- }
- return s.store.ListChannels(ctx, workspaceID)
-}
-func (s *ChannelService) Status(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID) (model.ChannelRuntimeStatus, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return model.ChannelRuntimeStatus{}, err
- }
- if _, err := s.store.GetChannel(ctx, workspaceID, id); err != nil {
- return model.ChannelRuntimeStatus{}, err
- }
- status, err := s.store.GetChannelStatus(ctx, id)
- if errors.Is(err, repository.ErrNotFound) {
- return model.ChannelRuntimeStatus{ChannelInstanceID: id, State: "disabled"}, nil
- }
- return status, err
-}
-func (s *ChannelService) runtimeConfig(ctx context.Context, instance model.ChannelInstance) (channelcore.InstanceConfig, error) {
- values := map[string]string{}
- if instance.SecretRef != nil {
- ref := secretstore.SecretRef{ID: *instance.SecretRef, Scope: secretstore.Scope{WorkspaceID: instance.WorkspaceID, ResourceType: "channel_instance", ResourceID: instance.ID}}
- secrets, err := s.secrets.Get(ctx, ref)
- if err != nil {
- return channelcore.InstanceConfig{}, err
- }
- for key, value := range secrets {
- values[key] = value.Reveal()
- }
- }
- return channelcore.InstanceConfig{AccountID: instance.ID.String(), Values: cloneValues(instance.Config.Values), Secrets: values, SenderAllowList: append([]string(nil), instance.Config.SenderAllowList...), GroupPolicy: instance.Config.GroupPolicy}, nil
-}
-func secretValues(values map[string]string) secretstore.SecretValues {
- result := make(secretstore.SecretValues, len(values))
- for key, value := range values {
- result[key] = secretstore.NewSecretString(value)
- }
- return result
-}
-func cloneValues(values map[string]any) map[string]any {
- result := make(map[string]any, len(values))
- for key, value := range values {
- result[key] = value
- }
- return result
-}
diff --git a/internal/service/identity.go b/internal/service/identity.go
deleted file mode 100644
index 539ab06..0000000
--- a/internal/service/identity.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-var (
- ErrSubjectRequired = errors.New("identity subject is required")
- ErrRegistrationClosed = errors.New("registration is closed")
- ErrOnboardingRequired = errors.New("account onboarding is required")
- ErrAccountSuspended = errors.New("account is suspended")
- ErrAccountDeleted = errors.New("account is deleted")
- ErrInvitationInvalid = errors.New("invitation is invalid")
- ErrInsufficientRole = errors.New("insufficient workspace role")
-)
-
-type IdentityService struct {
- store repository.IdentityStore
- cfg config.Config
- clock func() time.Time
- newID func() (uuid.UUID, error)
-}
-
-type IdentityServiceOption func(*IdentityService)
-
-func WithIdentityClock(clock func() time.Time) IdentityServiceOption {
- return func(service *IdentityService) {
- if clock != nil {
- service.clock = clock
- }
- }
-}
-
-func WithIdentityIDGenerator(generator func() (uuid.UUID, error)) IdentityServiceOption {
- return func(service *IdentityService) {
- if generator != nil {
- service.newID = generator
- }
- }
-}
-
-func NewIdentityService(store repository.IdentityStore, cfg config.Config, opts ...IdentityServiceOption) *IdentityService {
- service := &IdentityService{store: store, cfg: cfg, clock: time.Now, newID: uuid.NewV7}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-
-func (s *IdentityService) Resolve(ctx context.Context, external model.ExternalIdentity) (model.Principal, error) {
- if strings.TrimSpace(external.Issuer) == "" || strings.TrimSpace(external.Subject) == "" || strings.TrimSpace(external.Username) == "" {
- return model.Principal{}, ErrSubjectRequired
- }
-
- user, err := s.store.GetUserByExternalIdentity(ctx, external.Issuer, external.Subject)
- if err == nil {
- if _, statusErr := principalForStatus(user); statusErr != nil {
- return model.Principal{}, statusErr
- }
- profileUnchanged := user.Username == external.Username && user.Email == external.Email && user.DisplayName == external.DisplayName
- recentlySeen := user.LastSeenAt != nil && s.clock().UTC().Sub(*user.LastSeenAt) < 15*time.Minute
- if profileUnchanged && recentlySeen {
- return model.Principal{User: user}, nil
- }
- user, err = s.store.UpsertUser(ctx, user.ID, external)
- if err != nil {
- return model.Principal{}, fmt.Errorf("refresh identity: %w", err)
- }
- return principalForStatus(user)
- }
- if !errors.Is(err, repository.ErrNotFound) {
- return model.Principal{}, fmt.Errorf("resolve identity: %w", err)
- }
- if s.cfg.Registration.Mode == "disabled" {
- return model.Principal{}, ErrRegistrationClosed
- }
-
- id, err := s.newID()
- if err != nil {
- return model.Principal{}, fmt.Errorf("generate user ID: %w", err)
- }
- user, err = s.store.UpsertUser(ctx, id, external)
- if err != nil {
- return model.Principal{}, fmt.Errorf("create pending identity: %w", err)
- }
- return principalForStatus(user)
-}
-
-func principalForStatus(user model.User) (model.Principal, error) {
- switch user.Status {
- case "pending", "active":
- return model.Principal{User: user}, nil
- case "suspended":
- return model.Principal{}, ErrAccountSuspended
- case "deleted":
- return model.Principal{}, ErrAccountDeleted
- default:
- return model.Principal{}, fmt.Errorf("unknown account status %q", user.Status)
- }
-}
-
-func (s *IdentityService) Session(ctx context.Context, principal model.Principal) (model.Session, error) {
- workspaces := []model.Workspace{}
- if principal.User.Status == "active" {
- var err error
- workspaces, err = s.store.ListWorkspaces(ctx, principal.User.ID)
- if err != nil {
- return model.Session{}, fmt.Errorf("list session workspaces: %w", err)
- }
- }
- return model.Session{
- User: principal.User,
- Workspaces: workspaces,
- Auth: model.SessionAuth{LogoutURL: strings.TrimRight(s.cfg.Identity.AuthURL, "/") + "/logout"},
- Registration: model.SessionRegistration{
- Mode: s.cfg.Registration.Mode, TermsVersion: s.cfg.Registration.TermsVersion,
- PrivacyVersion: s.cfg.Registration.PrivacyVersion,
- },
- }, nil
-}
-
-func (s *IdentityService) Workspace(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) (model.Workspace, error) {
- if principal.User.Status != "active" {
- return model.Workspace{}, ErrOnboardingRequired
- }
- return s.store.GetWorkspaceMembership(ctx, workspaceID, principal.User.ID)
-}
-
-func (s *IdentityService) AuthorizeWorkspace(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, roles ...string) (model.Workspace, error) {
- workspace, err := s.Workspace(ctx, principal, workspaceID)
- if err != nil {
- return model.Workspace{}, err
- }
- if len(roles) == 0 {
- return workspace, nil
- }
- for _, role := range roles {
- if workspace.Role == role {
- return workspace, nil
- }
- }
- return model.Workspace{}, repository.ErrNotFound
-}
diff --git a/internal/service/identity_test.go b/internal/service/identity_test.go
deleted file mode 100644
index db95d49..0000000
--- a/internal/service/identity_test.go
+++ /dev/null
@@ -1,137 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "testing"
- "time"
-
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository/memory"
- "github.com/stretchr/testify/require"
-)
-
-func TestPrincipalForAccountStatus(t *testing.T) {
- for _, status := range []string{"pending", "active"} {
- principal, err := principalForStatus(model.User{Status: status})
- require.NoError(t, err)
- require.Equal(t, status, principal.User.Status)
- }
-
- _, err := principalForStatus(model.User{Status: "suspended"})
- require.ErrorIs(t, err, ErrAccountSuspended)
- _, err = principalForStatus(model.User{Status: "deleted"})
- require.ErrorIs(t, err, ErrAccountDeleted)
- _, err = principalForStatus(model.User{Status: "unknown"})
- require.Error(t, err)
- require.False(t, errors.Is(err, ErrOnboardingRequired))
-}
-
-func TestIdentityResolveCreatesPendingWithoutWorkspace(t *testing.T) {
- clock := func() time.Time { return time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC) }
- store := memory.New(memory.WithClock(clock))
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- external := model.ExternalIdentity{
- Issuer: "https://github.com", Subject: "42", Username: "moon", DisplayName: "Moon User",
- }
-
- first, err := identities.Resolve(context.Background(), external)
- require.NoError(t, err)
- second, err := identities.Resolve(context.Background(), external)
- require.NoError(t, err)
- require.Equal(t, first.User.ID, second.User.ID)
- require.Equal(t, "pending", first.User.Status)
-
- session, err := identities.Session(context.Background(), second)
- require.NoError(t, err)
- require.Empty(t, session.Workspaces)
-}
-
-func TestIdentityResolveKeepsStableIDAcrossProfileChanges(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- first, err := identities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon", Email: "old@example.com", DisplayName: "Old Name",
- })
- require.NoError(t, err)
- updated, err := identities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon-renamed", Email: "new@example.com", DisplayName: "New Name",
- })
- require.NoError(t, err)
- require.Equal(t, first.User.ID, updated.User.ID)
- require.Equal(t, "moon-renamed", updated.User.Username)
- require.Equal(t, "new@example.com", updated.User.Email)
- require.Equal(t, "New Name", updated.User.DisplayName)
-}
-
-func TestIdentityResolveThrottlesLastSeenRefresh(t *testing.T) {
- now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
- clock := func() time.Time { return now }
- store := memory.New(memory.WithClock(clock))
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg, WithIdentityClock(clock))
- external := model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon"}
-
- first, err := identities.Resolve(t.Context(), external)
- require.NoError(t, err)
- require.NotNil(t, first.User.LastSeenAt)
- initialLastSeen := *first.User.LastSeenAt
-
- now = now.Add(14 * time.Minute)
- recent, err := identities.Resolve(t.Context(), external)
- require.NoError(t, err)
- require.Equal(t, initialLastSeen, *recent.User.LastSeenAt)
-
- now = now.Add(2 * time.Minute)
- refreshed, err := identities.Resolve(t.Context(), external)
- require.NoError(t, err)
- require.Equal(t, now, *refreshed.User.LastSeenAt)
-}
-
-func TestIdentityResolveNamespacesSubjectByIssuer(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- github, err := identities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: "https://github.com", Subject: "42", Username: "github-user",
- })
- require.NoError(t, err)
- other, err := identities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: "https://identity.example.com", Subject: "42", Username: "other-user",
- })
- require.NoError(t, err)
- require.NotEqual(t, github.User.ID, other.User.ID)
-}
-
-func TestRegistrationCompleteActivatesAndCreatesOnePersonalWorkspace(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: "https://github.com", Subject: "42", Username: "moon", Email: "moon@example.com",
- })
- require.NoError(t, err)
-
- first, err := registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- second, err := registrations.Complete(t.Context(), model.Principal{User: first.User}, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- require.Equal(t, "active", first.User.Status)
- require.Len(t, first.Workspaces, 1)
- require.Len(t, second.Workspaces, 1)
- require.Equal(t, first.Workspaces[0].ID, second.Workspaces[0].ID)
-}
-
-func identityTestConfig(mode string) config.Config {
- return config.Config{
- Identity: config.IdentityConfig{Issuer: "https://github.com", AuthURL: "https://auth.example.com"},
- Registration: config.RegistrationConfig{
- Mode: mode, TermsVersion: "terms-v1", PrivacyVersion: "privacy-v1",
- InvitationTTL: 7 * 24 * time.Hour, CreatePersonalWorkspace: true,
- },
- }
-}
diff --git a/internal/service/message.go b/internal/service/message.go
deleted file mode 100644
index a009602..0000000
--- a/internal/service/message.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/base64"
- "errors"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-type MessagePage struct {
- Items []model.IMMessage `json:"items"`
- NextCursor string `json:"nextCursor,omitempty"`
-}
-type MessageService struct {
- store repository.ChannelStore
- identities *IdentityService
-}
-
-func NewMessageService(store repository.ChannelStore, identities *IdentityService) *MessageService {
- return &MessageService{store: store, identities: identities}
-}
-func (s *MessageService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, channelID, conversationID *uuid.UUID, cursor string, pageSize int32) (MessagePage, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return MessagePage{}, err
- }
- if pageSize <= 0 || pageSize > 100 {
- pageSize = 50
- }
- decoded, err := decodeTimeCursor(cursor)
- if err != nil {
- return MessagePage{}, err
- }
- items, err := s.store.ListMessages(ctx, workspaceID, channelID, conversationID, decoded, pageSize+1)
- if err != nil {
- return MessagePage{}, err
- }
- page := MessagePage{Items: items}
- if len(items) > int(pageSize) {
- page.Items = items[:pageSize]
- last := page.Items[len(page.Items)-1]
- page.NextCursor = encodeTimeCursor(last.OccurredAt, last.ID)
- }
- return page, nil
-}
-func (s *MessageService) Conversations(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.IMConversation, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return nil, err
- }
- return s.store.ListConversations(ctx, workspaceID, 100)
-}
-func decodeTimeCursor(value string) (repository.TimeCursor, error) {
- if value == "" {
- return repository.MessageCursor{}, nil
- }
- decoded, err := base64.RawURLEncoding.DecodeString(value)
- if err != nil {
- return repository.MessageCursor{}, errors.New("invalid message cursor")
- }
- parts := strings.Split(string(decoded), "|")
- if len(parts) != 2 {
- return repository.MessageCursor{}, errors.New("invalid message cursor")
- }
- timestamp, err := time.Parse(time.RFC3339Nano, parts[0])
- if err != nil {
- return repository.MessageCursor{}, errors.New("invalid message cursor")
- }
- id, err := uuid.Parse(parts[1])
- if err != nil {
- return repository.MessageCursor{}, errors.New("invalid message cursor")
- }
- return repository.MessageCursor{BeforeTime: ×tamp, BeforeID: &id}, nil
-}
-
-func encodeTimeCursor(timestamp time.Time, id uuid.UUID) string {
- return base64.RawURLEncoding.EncodeToString([]byte(timestamp.Format(time.RFC3339Nano) + "|" + id.String()))
-}
diff --git a/internal/service/mutation.go b/internal/service/mutation.go
deleted file mode 100644
index b788fa1..0000000
--- a/internal/service/mutation.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package service
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-func newMutationRequest(workspaceID uuid.UUID, operation, resourceKey, idempotencyKey string, body any, resourceID uuid.UUID, activeFor, cooldown time.Duration, now time.Time) (repository.MutationRequest, error) {
- encoded, err := json.Marshal(body)
- if err != nil {
- return repository.MutationRequest{}, err
- }
- digest := sha256.Sum256(encoded)
- key := strings.TrimSpace(idempotencyKey)
- if key == "" {
- key = uuid.NewString()
- }
- return repository.MutationRequest{
- WorkspaceID: workspaceID, Operation: operation, ResourceKey: resourceKey,
- IdempotencyKey: key, RequestHash: hex.EncodeToString(digest[:]), ResourceID: resourceID,
- ActiveUntil: now.Add(activeFor), NextAllowedAt: now.Add(cooldown),
- }, nil
-}
-
-func finishMutation(store repository.MutationStore, request repository.MutationRequest, succeeded bool) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- _ = store.FinishMutation(ctx, request, succeeded)
-}
diff --git a/internal/service/overview.go b/internal/service/overview.go
deleted file mode 100644
index 067759e..0000000
--- a/internal/service/overview.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package service
-
-import (
- "context"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-type OverviewService struct {
- metadata repository.MetadataStore
- channels repository.ChannelStore
- identities *IdentityService
-}
-
-func NewOverviewService(metadata repository.MetadataStore, channels repository.ChannelStore, identities *IdentityService) *OverviewService {
- return &OverviewService{metadata: metadata, channels: channels, identities: identities}
-}
-
-func (s *OverviewService) Get(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) (model.WorkspaceOverview, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return model.WorkspaceOverview{}, err
- }
- overview, err := s.metadata.GetWorkspaceOverview(ctx, workspaceID)
- if err != nil {
- return model.WorkspaceOverview{}, err
- }
- messages, err := s.channels.ListMessages(ctx, workspaceID, nil, nil, repository.MessageCursor{}, 5)
- if err != nil {
- return model.WorkspaceOverview{}, err
- }
- overview.RecentMessages = messages
- return overview, nil
-}
diff --git a/internal/service/registration.go b/internal/service/registration.go
deleted file mode 100644
index 49d2efe..0000000
--- a/internal/service/registration.go
+++ /dev/null
@@ -1,337 +0,0 @@
-package service
-
-import (
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "errors"
- "fmt"
- "io"
- "net/mail"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-var (
- ErrInvitationRequired = errors.New("a workspace invitation is required")
- ErrAgreementsRequired = errors.New("terms and privacy policy must be accepted")
-)
-
-type RegistrationService struct {
- store repository.IdentityStore
- identities *IdentityService
- cfg config.Config
- clock func() time.Time
- newID func() (uuid.UUID, error)
- random io.Reader
-}
-
-type RegistrationServiceOption func(*RegistrationService)
-
-func WithRegistrationClock(clock func() time.Time) RegistrationServiceOption {
- return func(service *RegistrationService) {
- if clock != nil {
- service.clock = clock
- }
- }
-}
-
-func WithRegistrationIDGenerator(generator func() (uuid.UUID, error)) RegistrationServiceOption {
- return func(service *RegistrationService) {
- if generator != nil {
- service.newID = generator
- }
- }
-}
-
-func WithRegistrationRandom(reader io.Reader) RegistrationServiceOption {
- return func(service *RegistrationService) {
- if reader != nil {
- service.random = reader
- }
- }
-}
-
-func NewRegistrationService(store repository.IdentityStore, identities *IdentityService, cfg config.Config, opts ...RegistrationServiceOption) *RegistrationService {
- service := &RegistrationService{store: store, identities: identities, cfg: cfg, clock: time.Now, newID: uuid.NewV7, random: rand.Reader}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-
-type CompleteRegistrationInput struct {
- AcceptTerms bool
- AcceptPrivacy bool
- RequestID string
-}
-
-func (s *RegistrationService) Complete(ctx context.Context, principal model.Principal, input CompleteRegistrationInput) (model.Session, error) {
- if s.cfg.Registration.Mode != "public" {
- if s.cfg.Registration.Mode == "invite_only" {
- return model.Session{}, ErrInvitationRequired
- }
- return model.Session{}, ErrRegistrationClosed
- }
- if !input.AcceptTerms || !input.AcceptPrivacy {
- return model.Session{}, ErrAgreementsRequired
- }
-
- user := principal.User
- err := s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- locked, err := tx.GetUserForUpdate(ctx, principal.User.ID)
- if err != nil {
- return err
- }
- if locked.Status == "active" {
- user = locked
- return nil
- }
- if locked.Status != "pending" {
- return accountStatusError(locked.Status)
- }
- user, err = tx.ActivateUser(ctx, locked.ID, s.cfg.Registration.TermsVersion, s.cfg.Registration.PrivacyVersion)
- if err != nil {
- return err
- }
- if s.cfg.Registration.CreatePersonalWorkspace {
- if err := s.ensurePersonalWorkspace(ctx, tx, user); err != nil {
- return err
- }
- }
- return s.appendAccountAudit(ctx, tx, user.ID, "account.activate", input.RequestID, map[string]any{"registrationMode": "public"})
- })
- if err != nil {
- return model.Session{}, fmt.Errorf("complete registration: %w", err)
- }
- return s.identities.Session(ctx, model.Principal{User: user})
-}
-
-type CreateInvitationInput struct {
- Email string
- Role string
-}
-
-func (s *RegistrationService) CreateInvitation(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateInvitationInput) (model.WorkspaceInvitation, error) {
- workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin")
- if err != nil {
- return model.WorkspaceInvitation{}, err
- }
- email, err := normalizeEmail(input.Email)
- if err != nil {
- return model.WorkspaceInvitation{}, err
- }
- role := strings.ToLower(strings.TrimSpace(input.Role))
- if role != "admin" && role != "member" && role != "viewer" {
- return model.WorkspaceInvitation{}, errors.New("invitation role must be admin, member, or viewer")
- }
- if workspace.Role != "owner" && role == "admin" {
- return model.WorkspaceInvitation{}, ErrInsufficientRole
- }
-
- tokenBytes := make([]byte, 32)
- if _, err := io.ReadFull(s.random, tokenBytes); err != nil {
- return model.WorkspaceInvitation{}, fmt.Errorf("generate invitation token: %w", err)
- }
- token := base64.RawURLEncoding.EncodeToString(tokenBytes)
- digest := sha256.Sum256([]byte(token))
- id, err := s.newID()
- if err != nil {
- return model.WorkspaceInvitation{}, err
- }
- invitation := model.WorkspaceInvitation{
- ID: id, WorkspaceID: workspaceID, Email: email, Role: role, InvitedBy: principal.User.ID,
- ExpiresAt: s.clock().UTC().Add(s.cfg.Registration.InvitationTTL),
- }
- err = s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- created, err := tx.CreateWorkspaceInvitation(ctx, invitation, digest[:])
- if err != nil {
- return err
- }
- invitation = created
- metadata := []byte(`{"role":"` + role + `"}`)
- return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.invitation_create", "invitation", id, metadata)
- })
- if err != nil {
- return model.WorkspaceInvitation{}, err
- }
- invitation.Token = token
- return invitation, nil
-}
-
-func (s *RegistrationService) ListInvitations(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.WorkspaceInvitation, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil {
- return nil, err
- }
- return s.store.ListWorkspaceInvitations(ctx, workspaceID)
-}
-
-func (s *RegistrationService) RevokeInvitation(ctx context.Context, principal model.Principal, workspaceID, invitationID uuid.UUID) error {
- workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin")
- if err != nil {
- return err
- }
- return s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- invitations, err := tx.ListWorkspaceInvitations(ctx, workspaceID)
- if err != nil {
- return err
- }
- var target *model.WorkspaceInvitation
- for index := range invitations {
- if invitations[index].ID == invitationID {
- target = &invitations[index]
- break
- }
- }
- if target == nil {
- return repository.ErrNotFound
- }
- if workspace.Role != "owner" && target.Role == "admin" {
- return ErrInsufficientRole
- }
- if err := tx.RevokeWorkspaceInvitation(ctx, invitationID); err != nil {
- return err
- }
- return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.invitation_revoke", "invitation", invitationID, []byte(`{}`))
- })
-}
-
-type AcceptInvitationInput struct {
- Token string
- AcceptTerms bool
- AcceptPrivacy bool
- RequestID string
-}
-
-func (s *RegistrationService) AcceptInvitation(ctx context.Context, principal model.Principal, input AcceptInvitationInput) (model.Session, error) {
- if !input.AcceptTerms || !input.AcceptPrivacy {
- return model.Session{}, ErrAgreementsRequired
- }
- if s.cfg.Registration.Mode == "disabled" && principal.User.Status != "active" {
- return model.Session{}, ErrRegistrationClosed
- }
- token := strings.TrimSpace(input.Token)
- if token == "" || len(token) > 256 {
- return model.Session{}, ErrInvitationInvalid
- }
- digest := sha256.Sum256([]byte(token))
- user := principal.User
- err := s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- invitation, err := tx.GetWorkspaceInvitationForUpdate(ctx, digest[:])
- if err != nil {
- return ErrInvitationInvalid
- }
- now := s.clock().UTC()
- if invitation.AcceptedAt != nil || invitation.RevokedAt != nil || !now.Before(invitation.ExpiresAt) {
- return ErrInvitationInvalid
- }
- email, err := normalizeEmail(principal.User.Email)
- if err != nil || email != invitation.Email {
- return ErrInvitationInvalid
- }
- locked, err := tx.GetUserForUpdate(ctx, principal.User.ID)
- if err != nil {
- return err
- }
- switch locked.Status {
- case "pending":
- user, err = tx.ActivateUser(ctx, locked.ID, s.cfg.Registration.TermsVersion, s.cfg.Registration.PrivacyVersion)
- if err != nil {
- return err
- }
- if s.cfg.Registration.CreatePersonalWorkspace {
- if err := s.ensurePersonalWorkspace(ctx, tx, user); err != nil {
- return err
- }
- }
- case "active":
- user = locked
- default:
- return accountStatusError(locked.Status)
- }
- if err := tx.AddWorkspaceMember(ctx, invitation.WorkspaceID, user.ID, invitation.Role); err != nil {
- if errors.Is(err, repository.ErrConflict) {
- return ErrInvitationInvalid
- }
- return err
- }
- if err := tx.AcceptWorkspaceInvitation(ctx, invitation.ID, user.ID); err != nil {
- return ErrInvitationInvalid
- }
- if err := tx.AppendIdentityAudit(ctx, invitation.WorkspaceID, user.ID, "workspace.invitation_accept", "invitation", invitation.ID, []byte(`{}`)); err != nil {
- return err
- }
- return s.appendAccountAudit(ctx, tx, user.ID, "account.invitation_accept", input.RequestID, map[string]any{"workspaceId": invitation.WorkspaceID.String()})
- })
- if err != nil {
- if errors.Is(err, ErrInvitationInvalid) {
- return model.Session{}, ErrInvitationInvalid
- }
- return model.Session{}, fmt.Errorf("accept invitation: %w", err)
- }
- return s.identities.Session(ctx, model.Principal{User: user})
-}
-
-func (s *RegistrationService) ensurePersonalWorkspace(ctx context.Context, tx repository.IdentityStore, user model.User) error {
- if _, err := tx.GetPersonalWorkspace(ctx, user.ID); err == nil {
- return nil
- } else if !errors.Is(err, repository.ErrNotFound) {
- return err
- }
- id, err := s.newID()
- if err != nil {
- return err
- }
- name := strings.TrimSpace(user.DisplayName)
- if name == "" {
- name = user.Username
- }
- workspace := model.Workspace{
- ID: id, Name: name + "'s workspace", Slug: "personal-" + strings.ReplaceAll(id.String(), "-", "")[:16],
- Kind: "personal", CreatedBy: user.ID, Role: "owner",
- }
- if _, err := tx.CreateWorkspace(ctx, workspace); err != nil {
- return err
- }
- return tx.AddWorkspaceMember(ctx, id, user.ID, "owner")
-}
-
-func (s *RegistrationService) appendAccountAudit(ctx context.Context, tx repository.IdentityStore, userID uuid.UUID, action, requestID string, metadata map[string]any) error {
- id, err := s.newID()
- if err != nil {
- return err
- }
- actor := userID
- return tx.AppendAccountAudit(ctx, model.AccountAuditLog{
- ID: id, UserID: userID, ActorUserID: &actor, Action: action, Result: "success",
- Provider: s.cfg.Identity.Issuer, RequestID: requestID, Metadata: metadata, OccurredAt: s.clock().UTC(),
- })
-}
-
-func normalizeEmail(value string) (string, error) {
- value = strings.TrimSpace(value)
- parsed, err := mail.ParseAddress(value)
- if err != nil || !strings.EqualFold(parsed.Address, value) {
- return "", errors.New("a valid email address is required")
- }
- return strings.ToLower(parsed.Address), nil
-}
-
-func accountStatusError(status string) error {
- switch status {
- case "suspended":
- return ErrAccountSuspended
- case "deleted":
- return ErrAccountDeleted
- default:
- return ErrOnboardingRequired
- }
-}
diff --git a/internal/service/registration_test.go b/internal/service/registration_test.go
deleted file mode 100644
index bcb5efa..0000000
--- a/internal/service/registration_test.go
+++ /dev/null
@@ -1,262 +0,0 @@
-package service
-
-import (
- "errors"
- "sync"
- "testing"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/repository/memory"
- "github.com/stretchr/testify/require"
-)
-
-func TestDisabledRegistrationDoesNotCreateUnknownUser(t *testing.T) {
- store := memory.New()
- disabled := identityTestConfig("disabled")
- identities := NewIdentityService(store, disabled)
- external := model.ExternalIdentity{Issuer: disabled.Identity.Issuer, Subject: "42", Username: "moon"}
-
- _, err := identities.Resolve(t.Context(), external)
- require.ErrorIs(t, err, ErrRegistrationClosed)
- _, err = store.GetUserByExternalIdentity(t.Context(), external.Issuer, external.Subject)
- require.ErrorIs(t, err, repository.ErrNotFound)
-}
-
-func TestDisabledRegistrationAllowsExistingActiveUser(t *testing.T) {
- store := memory.New()
- publicConfig := identityTestConfig("public")
- publicIdentities := NewIdentityService(store, publicConfig)
- publicRegistrations := NewRegistrationService(store, publicIdentities, publicConfig)
- active := activateTestUser(t, publicIdentities, publicRegistrations, "42", "moon@example.com")
-
- disabledConfig := identityTestConfig("disabled")
- disabledIdentities := NewIdentityService(store, disabledConfig)
- resolved, err := disabledIdentities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: disabledConfig.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com",
- })
- require.NoError(t, err)
- require.Equal(t, active.User.ID, resolved.User.ID)
- require.Equal(t, "active", resolved.User.Status)
-}
-
-func TestInviteOnlyRegistrationCannotCompleteWithoutInvitation(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("invite_only")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com",
- })
- require.NoError(t, err)
-
- _, err = registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.ErrorIs(t, err, ErrInvitationRequired)
-}
-
-func TestDisabledRegistrationCannotActivateExistingPendingUser(t *testing.T) {
- store := memory.New()
- inviteConfig := identityTestConfig("invite_only")
- inviteIdentities := NewIdentityService(store, inviteConfig)
- pending, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{
- Issuer: inviteConfig.Identity.Issuer, Subject: "42", Username: "moon", Email: "moon@example.com",
- })
- require.NoError(t, err)
- require.Equal(t, "pending", pending.User.Status)
-
- disabledConfig := identityTestConfig("disabled")
- disabledRegistrations := NewRegistrationService(store, NewIdentityService(store, disabledConfig), disabledConfig)
- _, err = disabledRegistrations.AcceptInvitation(t.Context(), pending, AcceptInvitationInput{
- Token: "unused", AcceptTerms: true, AcceptPrivacy: true,
- })
- require.ErrorIs(t, err, ErrRegistrationClosed)
-}
-
-func TestInvitationRegistrationIsOneTimeAndEmailBound(t *testing.T) {
- store := memory.New()
- publicConfig := identityTestConfig("public")
- publicIdentities := NewIdentityService(store, publicConfig)
- publicRegistrations := NewRegistrationService(store, publicIdentities, publicConfig)
- owner := activateTestUser(t, publicIdentities, publicRegistrations, "owner", "owner@example.com")
- workspaces := NewWorkspaceService(store, publicIdentities)
- workspace, err := workspaces.Create(t.Context(), owner, "Engineering")
- require.NoError(t, err)
-
- inviteConfig := identityTestConfig("invite_only")
- inviteIdentities := NewIdentityService(store, inviteConfig)
- invitations := NewRegistrationService(store, inviteIdentities, inviteConfig)
- created, err := invitations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: "Member@Example.com", Role: "member"})
- require.NoError(t, err)
- require.NotEmpty(t, created.Token)
-
- listed, err := invitations.ListInvitations(t.Context(), owner, workspace.ID)
- require.NoError(t, err)
- require.Len(t, listed, 1)
- require.Empty(t, listed[0].Token)
-
- wrongUser, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{Issuer: inviteConfig.Identity.Issuer, Subject: "wrong", Username: "wrong", Email: "wrong@example.com"})
- require.NoError(t, err)
- _, err = invitations.AcceptInvitation(t.Context(), wrongUser, AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.ErrorIs(t, err, ErrInvitationInvalid)
-
- member, err := inviteIdentities.Resolve(t.Context(), model.ExternalIdentity{Issuer: inviteConfig.Identity.Issuer, Subject: "member", Username: "member", Email: "member@example.com"})
- require.NoError(t, err)
- session, err := invitations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- require.Equal(t, "active", session.User.Status)
- require.Len(t, session.Workspaces, 2)
-
- _, err = invitations.AcceptInvitation(t.Context(), model.Principal{User: session.User}, AcceptInvitationInput{Token: created.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.ErrorIs(t, err, ErrInvitationInvalid)
-}
-
-func TestInvitationRejectsExpiredAndRevokedTokens(t *testing.T) {
- now := time.Date(2026, 7, 29, 0, 0, 0, 0, time.UTC)
- clock := func() time.Time { return now }
- store := memory.New(memory.WithClock(clock))
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg, WithIdentityClock(clock))
- registrations := NewRegistrationService(store, identities, cfg, WithRegistrationClock(clock))
- owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com")
- member := activateTestUser(t, identities, registrations, "member", "member@example.com")
- workspace, err := NewWorkspaceService(store, identities).Create(t.Context(), owner, "Engineering")
- require.NoError(t, err)
-
- expired, err := registrations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: member.User.Email, Role: "member"})
- require.NoError(t, err)
- now = now.Add(cfg.Registration.InvitationTTL)
- _, err = registrations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{Token: expired.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.ErrorIs(t, err, ErrInvitationInvalid)
-
- now = now.Add(time.Minute)
- revoked, err := registrations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: member.User.Email, Role: "member"})
- require.NoError(t, err)
- require.NoError(t, registrations.RevokeInvitation(t.Context(), owner, workspace.ID, revoked.ID))
- _, err = registrations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{Token: revoked.Token, AcceptTerms: true, AcceptPrivacy: true})
- require.ErrorIs(t, err, ErrInvitationInvalid)
-}
-
-func TestInvitationHasExactlyOneConcurrentConsumer(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com")
- member := activateTestUser(t, identities, registrations, "member", "member@example.com")
- workspace, err := NewWorkspaceService(store, identities).Create(t.Context(), owner, "Engineering")
- require.NoError(t, err)
- invitation, err := registrations.CreateInvitation(t.Context(), owner, workspace.ID, CreateInvitationInput{Email: member.User.Email, Role: "member"})
- require.NoError(t, err)
-
- const attempts = 8
- results := make(chan error, attempts)
- var group sync.WaitGroup
- for range attempts {
- group.Add(1)
- go func() {
- defer group.Done()
- _, acceptErr := registrations.AcceptInvitation(t.Context(), member, AcceptInvitationInput{
- Token: invitation.Token, AcceptTerms: true, AcceptPrivacy: true,
- })
- results <- acceptErr
- }()
- }
- group.Wait()
- close(results)
-
- succeeded := 0
- for result := range results {
- if result == nil {
- succeeded++
- continue
- }
- require.ErrorIs(t, result, ErrInvitationInvalid)
- }
- require.Equal(t, 1, succeeded)
-}
-
-func TestAdminCannotGrantOrRevokeAdmin(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- workspaces := NewWorkspaceService(store, identities)
- owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com")
- admin := activateTestUser(t, identities, registrations, "admin", "admin@example.com")
- member := activateTestUser(t, identities, registrations, "member", "member@example.com")
- workspace, err := workspaces.Create(t.Context(), owner, "Engineering")
- require.NoError(t, err)
- require.ErrorIs(t, workspaces.SetMember(t.Context(), owner, workspace.ID, admin.User.ID, "admin"), repository.ErrNotFound)
- admin = inviteTestMember(t, registrations, owner, admin, workspace.ID, "admin")
- member = inviteTestMember(t, registrations, owner, member, workspace.ID, "member")
-
- _, err = registrations.CreateInvitation(t.Context(), admin, workspace.ID, CreateInvitationInput{Email: "next@example.com", Role: "admin"})
- require.ErrorIs(t, err, ErrInsufficientRole)
- require.ErrorIs(t, workspaces.SetMember(t.Context(), admin, workspace.ID, member.User.ID, "admin"), ErrInsufficientRole)
- require.ErrorIs(t, workspaces.RemoveMember(t.Context(), admin, workspace.ID, admin.User.ID), ErrInsufficientRole)
- require.NoError(t, workspaces.RemoveMember(t.Context(), admin, workspace.ID, member.User.ID))
-}
-
-func TestInvitationCannotReplaceExistingMembership(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- workspaces := NewWorkspaceService(store, identities)
- owner := activateTestUser(t, identities, registrations, "owner", "owner@example.com")
- admin := activateTestUser(t, identities, registrations, "admin", "admin@example.com")
- workspace, err := workspaces.Create(t.Context(), owner, "Engineering")
- require.NoError(t, err)
- admin = inviteTestMember(t, registrations, owner, admin, workspace.ID, "admin")
-
- invitation, err := registrations.CreateInvitation(t.Context(), admin, workspace.ID, CreateInvitationInput{
- Email: owner.User.Email,
- Role: "member",
- })
- require.NoError(t, err)
-
- _, err = registrations.AcceptInvitation(t.Context(), owner, AcceptInvitationInput{
- Token: invitation.Token,
- AcceptTerms: true,
- AcceptPrivacy: true,
- })
- require.ErrorIs(t, err, ErrInvitationInvalid)
-
- membership, err := identities.Workspace(t.Context(), owner, workspace.ID)
- require.NoError(t, err)
- require.Equal(t, "owner", membership.Role)
-}
-
-func activateTestUser(t *testing.T, identities *IdentityService, registrations *RegistrationService, subject, email string) model.Principal {
- t.Helper()
- principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: identities.cfg.Identity.Issuer, Subject: subject, Username: subject, Email: email})
- require.NoError(t, err)
- session, err := registrations.Complete(t.Context(), principal, CompleteRegistrationInput{AcceptTerms: true, AcceptPrivacy: true})
- require.NoError(t, err)
- return model.Principal{User: session.User}
-}
-
-func inviteTestMember(t *testing.T, registrations *RegistrationService, inviter, invitee model.Principal, workspaceID uuid.UUID, role string) model.Principal {
- t.Helper()
- invitation, err := registrations.CreateInvitation(t.Context(), inviter, workspaceID, CreateInvitationInput{Email: invitee.User.Email, Role: role})
- require.NoError(t, err)
- session, err := registrations.AcceptInvitation(t.Context(), invitee, AcceptInvitationInput{
- Token: invitation.Token, AcceptTerms: true, AcceptPrivacy: true,
- })
- require.NoError(t, err)
- return model.Principal{User: session.User}
-}
-
-func TestPublicRegistrationRequiresAgreements(t *testing.T) {
- store := memory.New()
- cfg := identityTestConfig("public")
- identities := NewIdentityService(store, cfg)
- registrations := NewRegistrationService(store, identities, cfg)
- principal, err := identities.Resolve(t.Context(), model.ExternalIdentity{Issuer: cfg.Identity.Issuer, Subject: "42", Username: "moon"})
- require.NoError(t, err)
-
- _, err = registrations.Complete(t.Context(), principal, CompleteRegistrationInput{})
- require.True(t, errors.Is(err, ErrAgreementsRequired))
-}
diff --git a/internal/service/repository.go b/internal/service/repository.go
deleted file mode 100644
index 7fa5167..0000000
--- a/internal/service/repository.go
+++ /dev/null
@@ -1,290 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/checkout"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/pkg/scm"
-)
-
-type CreateRepositoryInput struct {
- Name string
- CloneURL string
- ProviderType string
- ConnectionID *uuid.UUID
- Ref string
- IdempotencyKey string
-}
-
-type RepositoryService struct {
- metadata repository.MetadataStore
- registry *scm.Registry
- identities *IdentityService
- checkouts checkout.Store
- clock func() time.Time
- cooldown time.Duration
-}
-
-type RepositoryServiceOption func(*RepositoryService)
-
-func WithRepositoryClock(clock func() time.Time) RepositoryServiceOption {
- return func(service *RepositoryService) {
- if clock != nil {
- service.clock = clock
- }
- }
-}
-func WithRepositoryCooldown(cooldown time.Duration) RepositoryServiceOption {
- return func(service *RepositoryService) {
- if cooldown >= 0 {
- service.cooldown = cooldown
- }
- }
-}
-
-func NewRepositoryService(metadata repository.MetadataStore, registry *scm.Registry, identities *IdentityService, checkouts checkout.Store, cfg config.Config, opts ...RepositoryServiceOption) *RepositoryService {
- service := &RepositoryService{metadata: metadata, registry: registry, identities: identities, checkouts: checkouts, clock: time.Now, cooldown: cfg.Git.SyncCooldown}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-
-func (s *RepositoryService) Create(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateRepositoryInput) (repo model.Repository, job model.Job, resultErr error) {
- resourceID := uuid.Nil
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.create", "repository", resourceID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.Repository{}, model.Job{}, err
- }
- input.Name, input.CloneURL, input.ProviderType, input.Ref = strings.TrimSpace(input.Name), strings.TrimSpace(input.CloneURL), strings.ToLower(strings.TrimSpace(input.ProviderType)), strings.TrimSpace(input.Ref)
- if input.Name == "" || input.CloneURL == "" {
- return model.Repository{}, model.Job{}, errors.New("repository name and clone URL are required")
- }
- if err := validateRepositoryRef(input.Ref); err != nil {
- return model.Repository{}, model.Job{}, err
- }
- providerType := input.ProviderType
- if input.ConnectionID != nil {
- connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, *input.ConnectionID)
- if err != nil {
- return model.Repository{}, model.Job{}, err
- }
- providerType = connection.Type
- }
- if providerType == "" {
- providerType = "generic"
- }
- provider, err := s.registry.Provider(providerType)
- if err != nil {
- return model.Repository{}, model.Job{}, err
- }
- remote, err := provider.Validate(ctx, input.CloneURL)
- if err != nil {
- return model.Repository{}, model.Job{}, fmt.Errorf("validate repository remote: %w", err)
- }
- repositoryID, err := uuid.NewV7()
- if err != nil {
- return model.Repository{}, model.Job{}, err
- }
- resourceID = repositoryID
- jobID, err := uuid.NewV7()
- if err != nil {
- return model.Repository{}, model.Job{}, err
- }
- payload, _ := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryID})
- now := s.clock().UTC()
- repo = model.Repository{ID: repositoryID, WorkspaceID: workspaceID, ConnectionID: input.ConnectionID, Name: input.Name, CloneURL: remote.URL, NormalizedURL: remote.URL, Ref: input.Ref, State: "pending"}
- job = model.Job{ID: jobID, WorkspaceID: workspaceID, Type: "repository.sync", Payload: payload, MaxAttempts: 5, RunAfter: now}
- key := strings.TrimSpace(input.IdempotencyKey)
- if key == "" {
- key = "repository-create:" + repositoryID.String()
- }
- err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- var txErr error
- repo, txErr = tx.CreateRepository(ctx, repo)
- if txErr != nil {
- return txErr
- }
- if txErr = tx.ReserveRepositorySync(ctx, workspaceID, repositoryID, now.Add(s.cooldown)); txErr != nil {
- return txErr
- }
- job, txErr = tx.CreateJob(ctx, job, key)
- if txErr != nil {
- return txErr
- }
- metadata, _ := json.Marshal(map[string]string{"provider": providerType})
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.create", "repository", repositoryID, metadata)
- })
- return repo, job, err
-}
-
-func (s *RepositoryService) Sync(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID, ref *string, idempotencyKey string) (job model.Job, resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.sync", "repository", repositoryID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.Job{}, err
- }
- repo, err := s.metadata.GetRepository(ctx, workspaceID, repositoryID)
- if err != nil {
- return model.Job{}, err
- }
- targetRef := repo.Ref
- if ref != nil {
- targetRef = strings.TrimSpace(*ref)
- }
- if err := validateRepositoryRef(targetRef); err != nil {
- return model.Job{}, err
- }
- jobID, err := uuid.NewV7()
- if err != nil {
- return model.Job{}, err
- }
- payload, _ := json.Marshal(model.RepositorySyncPayload{RepositoryID: repositoryID})
- now := s.clock().UTC()
- job = model.Job{ID: jobID, WorkspaceID: workspaceID, Type: "repository.sync", Payload: payload, MaxAttempts: 5, RunAfter: now}
- key := strings.TrimSpace(idempotencyKey)
- if key == "" {
- key = "repository-sync:" + jobID.String()
- }
- err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- if targetRef != repo.Ref {
- if err := tx.SetRepositoryRef(ctx, workspaceID, repositoryID, targetRef); err != nil {
- return err
- }
- }
- if err := tx.ReserveRepositorySync(ctx, workspaceID, repositoryID, now.Add(s.cooldown)); err != nil {
- return err
- }
- var txErr error
- job, txErr = tx.CreateJob(ctx, job, key)
- if txErr != nil {
- return txErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.sync", "repository", repositoryID, []byte(`{}`))
- })
- return job, err
-}
-
-func validateRepositoryRef(ref string) error {
- if len(ref) > 512 || strings.ContainsAny(ref, "\x00\r\n") {
- return errors.New("repository ref is invalid")
- }
- return nil
-}
-
-func (s *RepositoryService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.Repository, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return nil, err
- }
- return s.metadata.ListRepositories(ctx, workspaceID)
-}
-func (s *RepositoryService) Get(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID) (model.Repository, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return model.Repository{}, err
- }
- return s.metadata.GetRepository(ctx, workspaceID, repositoryID)
-}
-func (s *RepositoryService) Job(ctx context.Context, principal model.Principal, workspaceID, jobID uuid.UUID) (model.Job, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return model.Job{}, err
- }
- return s.metadata.GetJob(ctx, workspaceID, jobID)
-}
-
-func (s *RepositoryService) CancelJob(ctx context.Context, principal model.Principal, workspaceID, jobID uuid.UUID) (cancelled model.Job, resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "job.cancel", "job", jobID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.Job{}, err
- }
- existing, err := s.metadata.GetJob(ctx, workspaceID, jobID)
- if err != nil {
- return model.Job{}, err
- }
- if existing.Status != "queued" && existing.Status != "running" {
- return model.Job{}, repository.ErrConflict
- }
- err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- var txErr error
- cancelled, txErr = tx.CancelJob(ctx, workspaceID, jobID)
- if txErr != nil {
- return txErr
- }
- if cancelled.Type == "repository.sync" {
- var payload model.RepositorySyncPayload
- if json.Unmarshal(cancelled.Payload, &payload) == nil && payload.RepositoryID != uuid.Nil {
- if txErr = tx.MarkRepositoryFailed(ctx, workspaceID, payload.RepositoryID, "job.cancelled", "Synchronization was cancelled"); txErr != nil {
- return txErr
- }
- }
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "job.cancel", "job", jobID, []byte(`{}`))
- })
- return cancelled, err
-}
-
-func (s *RepositoryService) CancelSync(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID) (cancelled model.Job, resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.sync_cancel", "repository", repositoryID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.Job{}, err
- }
- if _, err := s.metadata.GetRepository(ctx, workspaceID, repositoryID); err != nil {
- return model.Job{}, err
- }
- err := s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- var txErr error
- cancelled, txErr = tx.CancelActiveRepositorySyncJob(ctx, workspaceID, repositoryID)
- if txErr != nil {
- if errors.Is(txErr, repository.ErrNotFound) {
- return repository.ErrConflict
- }
- return txErr
- }
- if txErr = tx.MarkRepositoryFailed(ctx, workspaceID, repositoryID, "job.cancelled", "Synchronization was cancelled"); txErr != nil {
- return txErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.sync_cancel", "repository", repositoryID, []byte(`{}`))
- })
- return cancelled, err
-}
-
-func (s *RepositoryService) Delete(ctx context.Context, principal model.Principal, workspaceID, repositoryID uuid.UUID) (resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "repository.delete", "repository", repositoryID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil {
- return err
- }
- if _, err := s.metadata.GetRepository(ctx, workspaceID, repositoryID); err != nil {
- return err
- }
- if err := s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- if err := tx.CancelRepositorySyncJobs(ctx, workspaceID, repositoryID); err != nil {
- return err
- }
- if _, err := tx.SoftDeleteRepository(ctx, workspaceID, repositoryID); err != nil {
- return err
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "repository.delete", "repository", repositoryID, []byte(`{}`))
- }); err != nil {
- return err
- }
- return s.checkouts.Remove(ctx, repositoryID)
-}
diff --git a/internal/service/scm_connection.go b/internal/service/scm_connection.go
deleted file mode 100644
index 6598abd..0000000
--- a/internal/service/scm_connection.go
+++ /dev/null
@@ -1,329 +0,0 @@
-package service
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/url"
- "strings"
- "time"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/config"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
- "github.com/mooncode-ai/mooncode/internal/secretstore"
- gitcore "github.com/mooncode-ai/mooncode/pkg/git"
- "github.com/mooncode-ai/mooncode/pkg/scm"
-)
-
-type CreateSCMConnectionInput struct {
- Type string
- Name string
- BaseURL string
- AuthType string
- Secrets map[string]string
-}
-
-type UpdateSCMConnectionInput struct {
- Name *string
- BaseURL *string
- AuthType *string
- Secrets map[string]string
- ClearSecrets bool
-}
-
-type SCMConnectionService struct {
- metadata repository.MetadataStore
- mutations repository.MutationStore
- secrets secretstore.SecretStore
- registry *scm.Registry
- identities *IdentityService
- git gitcore.Client
- clock func() time.Time
- gitConfig config.GitConfig
-}
-
-type SCMConnectionServiceOption func(*SCMConnectionService)
-
-func WithSCMConnectionClock(clock func() time.Time) SCMConnectionServiceOption {
- return func(service *SCMConnectionService) {
- if clock != nil {
- service.clock = clock
- }
- }
-}
-
-func NewSCMConnectionService(metadata repository.MetadataStore, mutations repository.MutationStore, secrets secretstore.SecretStore, registry *scm.Registry, identities *IdentityService, client gitcore.Client, cfg config.Config, opts ...SCMConnectionServiceOption) *SCMConnectionService {
- service := &SCMConnectionService{metadata: metadata, mutations: mutations, secrets: secrets, registry: registry, identities: identities, git: client, clock: time.Now, gitConfig: cfg.Git}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-
-func (s *SCMConnectionService) Create(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, input CreateSCMConnectionInput) (connection model.SCMConnection, resultErr error) {
- resourceID := uuid.Nil
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.create", "scm_connection", resourceID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.SCMConnection{}, err
- }
- input.Type, input.Name, input.BaseURL, input.AuthType = strings.ToLower(strings.TrimSpace(input.Type)), strings.TrimSpace(input.Name), strings.TrimSpace(input.BaseURL), strings.ToLower(strings.TrimSpace(input.AuthType))
- if err := validateSCMConnectionFields(input.Name, input.BaseURL, input.AuthType, input.Secrets); err != nil {
- return model.SCMConnection{}, err
- }
- provider, err := s.registry.Provider(input.Type)
- if err != nil {
- return model.SCMConnection{}, err
- }
- if _, err := provider.Validate(ctx, input.BaseURL+"/mooncode/validation.git"); err != nil {
- // Base URLs have no repository path; validate the actual origin by adding
- // a fixed non-sensitive path through the same policy.
- return model.SCMConnection{}, fmt.Errorf("validate SCM base URL: %w", err)
- }
- id, err := uuid.NewV7()
- if err != nil {
- return model.SCMConnection{}, err
- }
- resourceID = id
- connection = model.SCMConnection{ID: id, WorkspaceID: workspaceID, Type: input.Type, Name: input.Name, BaseURL: strings.TrimSuffix(input.BaseURL, "/"), AuthType: input.AuthType}
- var ref secretstore.SecretRef
- if len(input.Secrets) > 0 {
- values := make(secretstore.SecretValues, len(input.Secrets))
- for key, value := range input.Secrets {
- values[key] = secretstore.NewSecretString(value)
- }
- ref, err = s.secrets.Put(ctx, secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}, values)
- if err != nil {
- return model.SCMConnection{}, err
- }
- connection.SecretRef = &ref.ID
- }
- err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- var txErr error
- connection, txErr = tx.CreateSCMConnection(ctx, connection)
- if txErr != nil {
- return txErr
- }
- metadata, _ := json.Marshal(map[string]string{"type": connection.Type, "authType": connection.AuthType})
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.create", "scm_connection", id, metadata)
- })
- if err != nil {
- if ref.ID != uuid.Nil {
- _ = s.secrets.Delete(ctx, ref)
- }
- return model.SCMConnection{}, err
- }
- return connection, nil
-}
-
-func (s *SCMConnectionService) Update(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, input UpdateSCMConnectionInput) (connection model.SCMConnection, resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.update", "scm_connection", id, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return model.SCMConnection{}, err
- }
- connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, id)
- if err != nil {
- return model.SCMConnection{}, err
- }
- if input.Name != nil {
- connection.Name = strings.TrimSpace(*input.Name)
- }
- if input.BaseURL != nil {
- connection.BaseURL = strings.TrimSuffix(strings.TrimSpace(*input.BaseURL), "/")
- }
- if input.AuthType != nil {
- connection.AuthType = strings.ToLower(strings.TrimSpace(*input.AuthType))
- }
- provider, err := s.registry.Provider(connection.Type)
- if err != nil {
- return model.SCMConnection{}, err
- }
- if _, err := provider.Validate(ctx, connection.BaseURL+"/mooncode/validation.git"); err != nil {
- return model.SCMConnection{}, fmt.Errorf("validate SCM base URL: %w", err)
- }
- currentSecrets := map[string]string{}
- oldRef := secretstore.SecretRef{}
- if connection.SecretRef != nil {
- oldRef = secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}}
- if !input.ClearSecrets && input.Secrets != nil {
- values, getErr := s.secrets.Get(ctx, oldRef)
- if getErr != nil {
- return model.SCMConnection{}, getErr
- }
- for key, value := range values {
- currentSecrets[key] = value.Reveal()
- }
- }
- }
- for key, value := range input.Secrets {
- currentSecrets[key] = value
- }
- if err := validateSCMConnectionFields(connection.Name, connection.BaseURL, connection.AuthType, currentSecrets); err != nil {
- return model.SCMConnection{}, err
- }
- secretsChanged := input.ClearSecrets || input.Secrets != nil
- newRef := secretstore.SecretRef{}
- if secretsChanged {
- if len(currentSecrets) > 0 {
- newRef, err = s.secrets.Put(ctx, secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}, toSecretValues(currentSecrets))
- if err != nil {
- return model.SCMConnection{}, err
- }
- connection.SecretRef = &newRef.ID
- } else {
- connection.SecretRef = nil
- }
- }
- err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- var txErr error
- connection, txErr = tx.UpdateSCMConnection(ctx, connection)
- if txErr != nil {
- return txErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.update", "scm_connection", id, []byte(`{}`))
- })
- if err != nil {
- if newRef.ID != uuid.Nil {
- _ = s.secrets.Delete(ctx, newRef)
- }
- return model.SCMConnection{}, err
- }
- if secretsChanged && oldRef.ID != uuid.Nil {
- _ = s.secrets.Delete(ctx, oldRef)
- }
- return connection, nil
-}
-
-func (s *SCMConnectionService) Test(ctx context.Context, principal model.Principal, workspaceID, id uuid.UUID, remoteURL, idempotencyKey string) (resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.test", "scm_connection", id, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin", "member"); err != nil {
- return err
- }
- connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, id)
- if err != nil {
- return err
- }
- provider, err := s.registry.Provider(connection.Type)
- if err != nil {
- return err
- }
- remote, err := provider.Validate(ctx, strings.TrimSpace(remoteURL))
- if err != nil {
- return err
- }
- base, err := url.Parse(connection.BaseURL)
- if err != nil || !strings.EqualFold(base.Hostname(), remote.Host) || !strings.HasPrefix(remote.Path, strings.TrimSuffix(base.Path, "/")+"/") {
- return errors.New("probe repository must belong to the SCM connection origin")
- }
- credential := gitcore.Credential{}
- if connection.SecretRef != nil {
- ref := secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: id}}
- values, getErr := s.secrets.Get(ctx, ref)
- if getErr != nil {
- return getErr
- }
- credential = gitcore.NewCredential(revealSecret(values, "username"), revealSecret(values, "token"))
- }
- mutation, err := newMutationRequest(workspaceID, "scm_connection.test", id.String(), idempotencyKey, map[string]string{"repositoryUrl": remote.URL}, id, s.gitConfig.CloneTimeout, s.gitConfig.ProbeCooldown, s.clock().UTC())
- if err != nil {
- return err
- }
- claim, err := s.mutations.BeginMutation(ctx, mutation)
- if err != nil {
- return err
- }
- if claim.Replay {
- return nil
- }
- succeeded := false
- defer func() { finishMutation(s.mutations, mutation, succeeded) }()
- if err := s.git.Probe(ctx, gitcore.ProbeRequest{Provider: connection.Type, Remote: remote.URL, Credential: credential}); err != nil {
- return err
- }
- if err := s.metadata.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.test", "scm_connection", id, []byte(`{}`)); err != nil {
- return err
- }
- succeeded = true
- return nil
-}
-
-func validateSCMConnectionFields(name, baseURL, authType string, secrets map[string]string) error {
- if strings.TrimSpace(name) == "" || strings.TrimSpace(baseURL) == "" {
- return errors.New("connection name and base URL are required")
- }
- switch strings.ToLower(strings.TrimSpace(authType)) {
- case "none", "token", "basic":
- default:
- return errors.New("connection auth type must be none, token, or basic")
- }
- for key := range secrets {
- if key != "username" && key != "token" {
- return fmt.Errorf("unsupported connection secret field %q", key)
- }
- }
- if len(secrets) > 0 && strings.TrimSpace(secrets["token"]) == "" {
- return errors.New("connection token is required when credentials are configured")
- }
- return nil
-}
-
-func toSecretValues(values map[string]string) secretstore.SecretValues {
- result := make(secretstore.SecretValues, len(values))
- for key, value := range values {
- result[key] = secretstore.NewSecretString(value)
- }
- return result
-}
-
-func revealSecret(values secretstore.SecretValues, key string) string {
- if value, ok := values[key]; ok {
- return value.Reveal()
- }
- return ""
-}
-
-func (s *SCMConnectionService) List(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.SCMConnection, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return nil, err
- }
- return s.metadata.ListSCMConnections(ctx, workspaceID)
-}
-
-func (s *SCMConnectionService) Delete(ctx context.Context, principal model.Principal, workspaceID, connectionID uuid.UUID) (resultErr error) {
- defer func() {
- recordFailure(ctx, s.metadata, workspaceID, principal.User.ID, "scm_connection.delete", "scm_connection", connectionID, resultErr)
- }()
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin"); err != nil {
- return err
- }
- connection, err := s.metadata.GetSCMConnection(ctx, workspaceID, connectionID)
- if err != nil {
- return err
- }
- err = s.metadata.WithinMetadataTx(ctx, func(tx repository.MetadataStore) error {
- if _, deleteErr := tx.SoftDeleteSCMConnection(ctx, workspaceID, connectionID); deleteErr != nil {
- if errors.Is(deleteErr, repository.ErrNotFound) {
- return repository.ErrConflict
- }
- return deleteErr
- }
- return tx.AppendAudit(ctx, workspaceID, principal.User.ID, "scm_connection.delete", "scm_connection", connectionID, []byte(`{}`))
- })
- if err != nil {
- return err
- }
- if connection.SecretRef != nil {
- _ = s.secrets.Delete(ctx, secretstore.SecretRef{ID: *connection.SecretRef, Scope: secretstore.Scope{WorkspaceID: workspaceID, ResourceType: "scm_connection", ResourceID: connectionID}})
- }
- return nil
-}
diff --git a/internal/service/workspace.go b/internal/service/workspace.go
deleted file mode 100644
index c797c19..0000000
--- a/internal/service/workspace.go
+++ /dev/null
@@ -1,170 +0,0 @@
-package service
-
-import (
- "context"
- "errors"
- "strings"
- "unicode"
-
- "github.com/google/uuid"
- "github.com/mooncode-ai/mooncode/internal/model"
- "github.com/mooncode-ai/mooncode/internal/repository"
-)
-
-type WorkspaceService struct {
- store repository.IdentityStore
- identities *IdentityService
-}
-
-type WorkspaceServiceOption func(*WorkspaceService)
-
-func NewWorkspaceService(store repository.IdentityStore, identities *IdentityService, opts ...WorkspaceServiceOption) *WorkspaceService {
- service := &WorkspaceService{store: store, identities: identities}
- for _, option := range opts {
- if option != nil {
- option(service)
- }
- }
- return service
-}
-
-func (s *WorkspaceService) Create(ctx context.Context, principal model.Principal, name string) (model.Workspace, error) {
- name = strings.TrimSpace(name)
- if name == "" || len(name) > 120 {
- return model.Workspace{}, errors.New("workspace name is required and must not exceed 120 bytes")
- }
- id, err := uuid.NewV7()
- if err != nil {
- return model.Workspace{}, err
- }
- workspace := model.Workspace{ID: id, Name: name, Slug: workspaceSlug(name, id), Kind: "team", CreatedBy: principal.User.ID, Role: "owner"}
- err = s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- var txErr error
- workspace, txErr = tx.CreateWorkspace(ctx, workspace)
- if txErr != nil {
- return txErr
- }
- workspace.Role = "owner"
- if txErr = tx.AddWorkspaceMember(ctx, id, principal.User.ID, "owner"); txErr != nil {
- return txErr
- }
- return tx.AppendIdentityAudit(ctx, id, principal.User.ID, "workspace.create", "workspace", id, []byte(`{"kind":"team"}`))
- })
- return workspace, err
-}
-
-func (s *WorkspaceService) Update(ctx context.Context, principal model.Principal, workspaceID uuid.UUID, name string) (workspace model.Workspace, resultErr error) {
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, "workspace.update", "workspace", workspaceID, resultErr)
- }()
- authorized, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin")
- if err != nil {
- return model.Workspace{}, err
- }
- name = strings.TrimSpace(name)
- if name == "" || len(name) > 120 {
- return model.Workspace{}, errors.New("workspace name is required and must not exceed 120 bytes")
- }
- err = s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- var txErr error
- workspace, txErr = tx.UpdateWorkspace(ctx, workspaceID, name)
- if txErr != nil {
- return txErr
- }
- return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.update", "workspace", workspaceID, []byte(`{}`))
- })
- workspace.Role = authorized.Role
- return workspace, err
-}
-
-func (s *WorkspaceService) Members(ctx context.Context, principal model.Principal, workspaceID uuid.UUID) ([]model.WorkspaceMember, error) {
- if _, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID); err != nil {
- return nil, err
- }
- return s.store.ListWorkspaceMembers(ctx, workspaceID)
-}
-
-func (s *WorkspaceService) SetMember(ctx context.Context, principal model.Principal, workspaceID, userID uuid.UUID, role string) (resultErr error) {
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, "workspace.member_set", "user", userID, resultErr)
- }()
- workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin")
- if err != nil {
- return err
- }
- role = strings.ToLower(strings.TrimSpace(role))
- if !validWorkspaceRole(role) {
- return errors.New("workspace role must be owner, admin, member, or viewer")
- }
- if role == "owner" {
- return errors.New("workspace ownership must be transferred through the ownership workflow")
- }
- if userID == workspace.CreatedBy && role != "owner" {
- return errors.New("workspace creator must remain an owner")
- }
- target, targetErr := s.store.GetWorkspaceMembership(ctx, workspaceID, userID)
- if targetErr != nil {
- return targetErr
- }
- if workspace.Role != "owner" && (role == "admin" || target.Role == "owner" || target.Role == "admin") {
- return ErrInsufficientRole
- }
- return s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- if err := tx.UpsertWorkspaceMember(ctx, workspaceID, userID, role); err != nil {
- return err
- }
- return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.member_set", "user", userID, []byte(`{"role":"`+role+`"}`))
- })
-}
-
-func (s *WorkspaceService) RemoveMember(ctx context.Context, principal model.Principal, workspaceID, userID uuid.UUID) (resultErr error) {
- defer func() {
- recordFailure(ctx, s.store, workspaceID, principal.User.ID, "workspace.member_remove", "user", userID, resultErr)
- }()
- workspace, err := s.identities.AuthorizeWorkspace(ctx, principal, workspaceID, "owner", "admin")
- if err != nil {
- return err
- }
- if userID == workspace.CreatedBy {
- return errors.New("workspace creator cannot be removed")
- }
- target, targetErr := s.store.GetWorkspaceMembership(ctx, workspaceID, userID)
- if targetErr != nil {
- return targetErr
- }
- if workspace.Role != "owner" && (target.Role == "owner" || target.Role == "admin") {
- return ErrInsufficientRole
- }
- return s.store.WithinIdentityTx(ctx, func(tx repository.IdentityStore) error {
- if err := tx.DeleteWorkspaceMember(ctx, workspaceID, userID); err != nil {
- return err
- }
- return tx.AppendIdentityAudit(ctx, workspaceID, principal.User.ID, "workspace.member_remove", "user", userID, []byte(`{}`))
- })
-}
-
-func validWorkspaceRole(role string) bool {
- return role == "owner" || role == "admin" || role == "member" || role == "viewer"
-}
-
-func workspaceSlug(name string, id uuid.UUID) string {
- var builder strings.Builder
- lastHyphen := false
- for _, value := range strings.ToLower(name) {
- if unicode.IsLetter(value) || unicode.IsDigit(value) {
- builder.WriteRune(value)
- lastHyphen = false
- } else if builder.Len() > 0 && !lastHyphen {
- builder.WriteByte('-')
- lastHyphen = true
- }
- if builder.Len() >= 40 {
- break
- }
- }
- prefix := strings.Trim(builder.String(), "-")
- if prefix == "" {
- prefix = "workspace"
- }
- return prefix + "-" + strings.ReplaceAll(id.String(), "-", "")[:8]
-}
diff --git a/internal/testutil/testdb/testdb.go b/internal/testutil/testdb/testdb.go
new file mode 100644
index 0000000..5dc9abe
--- /dev/null
+++ b/internal/testutil/testdb/testdb.go
@@ -0,0 +1,67 @@
+//go:build integration
+
+// Package testdb provides isolated PostgreSQL databases for integration tests.
+package testdb
+
+import (
+ "context"
+ "testing"
+
+ "github.com/fuchencong/mooncode/internal/platform/database"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// Open creates a schema dedicated to the current test, migrates it, and
+// removes it when the test completes.
+func Open(t testing.TB, databaseURL string) *pgxpool.Pool {
+ t.Helper()
+
+ ctx := context.Background()
+ admin, err := database.Open(ctx, databaseURL, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ schema := "test_" + uuid.NewString()
+ identifier := pgx.Identifier{schema}.Sanitize()
+ if _, err := admin.Exec(ctx, "CREATE SCHEMA "+identifier); err != nil {
+ admin.Close()
+ t.Fatal(err)
+ }
+
+ config, err := pgxpool.ParseConfig(databaseURL)
+ if err != nil {
+ admin.Close()
+ t.Fatal(err)
+ }
+ config.MaxConns = 5
+ config.ConnConfig.RuntimeParams["search_path"] = identifier
+
+ pool, err := pgxpool.NewWithConfig(ctx, config)
+ if err != nil {
+ admin.Close()
+ t.Fatal(err)
+ }
+ if err := pool.Ping(ctx); err != nil {
+ pool.Close()
+ admin.Close()
+ t.Fatal(err)
+ }
+ if err := database.Migrate(ctx, pool); err != nil {
+ pool.Close()
+ admin.Close()
+ t.Fatal(err)
+ }
+
+ t.Cleanup(func() {
+ pool.Close()
+ if _, err := admin.Exec(context.Background(), "DROP SCHEMA "+identifier+" CASCADE"); err != nil {
+ t.Errorf("drop test schema: %v", err)
+ }
+ admin.Close()
+ })
+
+ return pool
+}
diff --git a/internal/version/version.go b/internal/version/version.go
deleted file mode 100644
index 30c3516..0000000
--- a/internal/version/version.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package version
-
-// These values are populated with -ldflags in release builds.
-var (
- Version = "dev"
- Commit = "none"
- Date = "unknown"
-)
-
-type Info struct {
- Version string
- Commit string
- Date string
-}
-
-func Current() Info {
- return Info{
- Version: Version,
- Commit: Commit,
- Date: Date,
- }
-}
diff --git a/internal/workflow/biz/failure.go b/internal/workflow/biz/failure.go
new file mode 100644
index 0000000..67e79ed
--- /dev/null
+++ b/internal/workflow/biz/failure.go
@@ -0,0 +1,29 @@
+package biz
+
+import "errors"
+
+type permanentError struct {
+ cause error
+}
+
+func Permanent(cause error) error {
+ if cause == nil || IsPermanent(cause) {
+ return cause
+ }
+
+ return &permanentError{cause: cause}
+}
+
+func (e *permanentError) Error() string {
+ return e.cause.Error()
+}
+
+func (e *permanentError) Unwrap() error {
+ return e.cause
+}
+
+func IsPermanent(err error) bool {
+ var target *permanentError
+
+ return errors.As(err, &target)
+}
diff --git a/internal/workflow/biz/failure_test.go b/internal/workflow/biz/failure_test.go
new file mode 100644
index 0000000..e4a5b32
--- /dev/null
+++ b/internal/workflow/biz/failure_test.go
@@ -0,0 +1,17 @@
+package biz
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestPermanentPreservesCause(t *testing.T) {
+ cause := errors.New("invalid input")
+ err := Permanent(cause)
+ if !IsPermanent(err) || !errors.Is(err, cause) {
+ t.Fatalf("permanent error did not preserve cause: %v", err)
+ }
+ if Permanent(err) != err {
+ t.Fatal("permanent error was wrapped more than once")
+ }
+}
diff --git a/internal/workflow/biz/model.go b/internal/workflow/biz/model.go
new file mode 100644
index 0000000..8c2cae5
--- /dev/null
+++ b/internal/workflow/biz/model.go
@@ -0,0 +1,55 @@
+package biz
+
+import (
+ "encoding/json"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+var ErrDispatchNotPending = errors.New("workflow dispatch is no longer pending")
+
+const (
+ AggregateRepositoryOperation = "repository_operation"
+ AggregateAnalysisRun = "analysis_run"
+ AggregateNotification = "notification"
+ AggregateRetentionCleanup = "retention_cleanup"
+
+ WorkflowRepositoryOperation = "repository-operation"
+ WorkflowAnalysisRun = "analysis-run"
+ WorkflowNotificationDelivery = "notification-delivery"
+ WorkflowRetentionCleanup = "retention-cleanup"
+)
+
+type Payload struct {
+ AggregateID uuid.UUID `json:"aggregateId"`
+ RepositoryID uuid.UUID `json:"repositoryId"`
+ ChannelID uuid.UUID `json:"channelId,omitempty"`
+ WorkspaceID uuid.UUID `json:"workspaceId,omitempty"`
+}
+
+func NewDispatch(aggregateType string, aggregateID uuid.UUID, workflowName string, payload Payload) (Dispatch, error) {
+ encoded, err := json.Marshal(payload)
+ if err != nil {
+ return Dispatch{}, err
+ }
+
+ return Dispatch{
+ ID: uuid.New(),
+ AggregateType: aggregateType,
+ AggregateID: aggregateID,
+ WorkflowName: workflowName,
+ Payload: encoded,
+ }, nil
+}
+
+type Dispatch struct {
+ ID uuid.UUID
+ AggregateType string
+ AggregateID uuid.UUID
+ WorkflowName string
+ Payload json.RawMessage
+ Attempts int32
+ AvailableAt time.Time
+}
diff --git a/internal/workflow/data/store.go b/internal/workflow/data/store.go
new file mode 100644
index 0000000..0826812
--- /dev/null
+++ b/internal/workflow/data/store.go
@@ -0,0 +1,97 @@
+package data
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ workflow "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type Store struct {
+ pool *pgxpool.Pool
+ queries *sqlc.Queries
+}
+
+func NewStore(pool *pgxpool.Pool) *Store {
+ return &Store{pool: pool, queries: sqlc.New(pool)}
+}
+
+func (s *Store) Claim(ctx context.Context, limit int32, lease time.Duration) ([]workflow.Dispatch, error) {
+ rows, err := s.queries.ClaimWorkflowDispatches(ctx, sqlc.ClaimWorkflowDispatchesParams{
+ LeaseDuration: pgtype.Interval{Microseconds: lease.Microseconds(), Valid: true},
+ Limit: limit,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ items := make([]workflow.Dispatch, 0, len(rows))
+ for _, row := range rows {
+ items = append(items, workflow.Dispatch{
+ ID: row.ID,
+ AggregateType: row.AggregateType,
+ AggregateID: row.AggregateID,
+ WorkflowName: row.WorkflowName,
+ Payload: row.Payload,
+ Attempts: row.Attempts,
+ AvailableAt: row.AvailableAt.Time,
+ })
+ }
+
+ return items, nil
+}
+
+func (s *Store) IsPending(ctx context.Context, id uuid.UUID) (bool, error) {
+ return s.queries.IsWorkflowDispatchPending(ctx, id)
+}
+
+func (s *Store) Complete(ctx context.Context, dispatch workflow.Dispatch, workflowRunID string) error {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+ q := s.queries.WithTx(tx)
+
+ switch dispatch.AggregateType {
+ case workflow.AggregateRepositoryOperation:
+ err = q.SetRepositoryOperationWorkflowID(ctx, sqlc.SetRepositoryOperationWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)})
+ case workflow.AggregateAnalysisRun:
+ err = q.SetAnalysisWorkflowID(ctx, sqlc.SetAnalysisWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)})
+ case workflow.AggregateNotification:
+ err = q.SetNotificationWorkflowID(ctx, sqlc.SetNotificationWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)})
+ case workflow.AggregateRetentionCleanup:
+ err = q.SetRetentionCleanupWorkflowID(ctx, sqlc.SetRetentionCleanupWorkflowIDParams{ID: dispatch.AggregateID, WorkflowRunID: text(workflowRunID)})
+ default:
+ return fmt.Errorf("unsupported workflow aggregate type %q", dispatch.AggregateType)
+ }
+ if err != nil {
+ return err
+ }
+ updated, err := q.MarkWorkflowDispatched(ctx, dispatch.ID)
+ if err != nil {
+ return err
+ }
+ if updated != 1 {
+ return workflow.ErrDispatchNotPending
+ }
+
+ return tx.Commit(ctx)
+}
+
+func (s *Store) Delay(ctx context.Context, id uuid.UUID, availableAt time.Time, message string) error {
+ return s.queries.DelayWorkflowDispatch(ctx, sqlc.DelayWorkflowDispatchParams{
+ ID: id,
+ AvailableAt: pgtype.Timestamptz{Time: availableAt, Valid: true},
+ LastError: text(message),
+ })
+}
+
+func text(value string) pgtype.Text {
+ return pgtype.Text{String: value, Valid: value != ""}
+}
diff --git a/internal/workflow/data/store_integration_test.go b/internal/workflow/data/store_integration_test.go
new file mode 100644
index 0000000..e31ff73
--- /dev/null
+++ b/internal/workflow/data/store_integration_test.go
@@ -0,0 +1,151 @@
+//go:build integration
+
+package data
+
+import (
+ "context"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/fuchencong/mooncode/internal/data/sqlc"
+ "github.com/fuchencong/mooncode/internal/testutil/testdb"
+ workflow "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/google/uuid"
+)
+
+func TestClaimLeaseAndCompleteWorkflowDispatch(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ userID, workspaceID, repositoryID, operationID := uuid.New(), uuid.New(), uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `INSERT INTO users (id,status) VALUES ($1,'active')`, userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO workspaces (id,name,slug,created_by) VALUES ($1,'Workflow test',$2,$3)`, workspaceID, "workflow-"+workspaceID.String(), userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `INSERT INTO repositories (id,workspace_id,provider_type,name,remote_url,normalized_url,configured_ref,git_path,status,created_by) VALUES ($1,$2,'github','repo','https://github.com/example/repo.git',$3,'main',$4,'provisioning',$5)`, repositoryID, workspaceID, "github.com/example/"+repositoryID.String(), "/tmp/"+repositoryID.String()+".git", userID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := pool.Exec(ctx, `
+INSERT INTO repository_operations (
+ id,repository_id,actor_user_id,repository_version,kind,
+ requested_provider_type,requested_remote_url,requested_normalized_url,requested_ref,
+ previous_provider_type,previous_remote_url,previous_normalized_url,previous_ref,status
+) VALUES (
+ $1,$2,$3,1,'purge',
+ 'github','https://github.com/example/repo.git',$4,'main',
+ 'github','https://github.com/example/repo.git',$4,'main','queued'
+)`, operationID, repositoryID, userID, "github.com/example/"+repositoryID.String()); err != nil {
+ t.Fatal(err)
+ }
+ dispatch, err := workflow.NewDispatch(workflow.AggregateRepositoryOperation, operationID, workflow.WorkflowRepositoryOperation, workflow.Payload{AggregateID: operationID, RepositoryID: repositoryID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ queries := sqlc.New(pool)
+ if _, err := queries.CreateWorkflowDispatch(ctx, sqlc.CreateWorkflowDispatchParams{ID: dispatch.ID, AggregateType: dispatch.AggregateType, AggregateID: dispatch.AggregateID, WorkflowName: dispatch.WorkflowName, Payload: dispatch.Payload}); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ claimed, err := store.Claim(ctx, 10, time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var claimedDispatch *workflow.Dispatch
+ for i := range claimed {
+ if claimed[i].ID == dispatch.ID {
+ claimedDispatch = &claimed[i]
+ break
+ }
+ }
+ if claimedDispatch == nil || claimedDispatch.Attempts != 1 {
+ t.Fatalf("dispatch was not claimed exactly once: %#v", claimed)
+ }
+ claimedAgain, err := store.Claim(ctx, 10, time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, item := range claimedAgain {
+ if item.ID == dispatch.ID {
+ t.Fatal("leased dispatch was claimed again")
+ }
+ }
+ if err := store.Complete(ctx, *claimedDispatch, "hatchet-run-id"); err != nil {
+ t.Fatal(err)
+ }
+ var status, workflowRunID string
+ if err := pool.QueryRow(ctx, `SELECT d.status,o.workflow_run_id FROM workflow_dispatches d JOIN repository_operations o ON o.id=d.aggregate_id WHERE d.id=$1`, dispatch.ID).Scan(&status, &workflowRunID); err != nil {
+ t.Fatal(err)
+ }
+ if status != "dispatched" || workflowRunID != "hatchet-run-id" {
+ t.Fatalf("unexpected completed dispatch: status=%q workflowRunID=%q", status, workflowRunID)
+ }
+}
+
+func TestHighAttemptDispatchRemainsPendingUntilCancelled(t *testing.T) {
+ databaseURL := os.Getenv("MOONCODE_TEST_DATABASE_URL")
+ if databaseURL == "" {
+ t.Skip("MOONCODE_TEST_DATABASE_URL is not set")
+ }
+ ctx := context.Background()
+ pool := testdb.Open(t, databaseURL)
+
+ dispatchID, aggregateID := uuid.New(), uuid.New()
+ if _, err := pool.Exec(ctx, `
+INSERT INTO workflow_dispatches (
+ id,aggregate_type,aggregate_id,workflow_name,payload,status,attempts
+) VALUES (
+ $1,'analysis_run',$2,'analysis-run',$3,'pending',99
+)`, dispatchID, aggregateID, `{"aggregateId":"`+aggregateID.String()+`","repositoryId":"`+uuid.NewString()+`"}`); err != nil {
+ t.Fatal(err)
+ }
+
+ store := NewStore(pool)
+ claimed, err := store.Claim(ctx, 100, time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var item *workflow.Dispatch
+ for i := range claimed {
+ if claimed[i].ID == dispatchID {
+ item = &claimed[i]
+ break
+ }
+ }
+ if item == nil || item.Attempts != 100 {
+ t.Fatalf("high-attempt dispatch was not claimed: %#v", item)
+ }
+ if err := store.Delay(ctx, dispatchID, time.Now().Add(time.Minute), "Hatchet unavailable"); err != nil {
+ t.Fatal(err)
+ }
+
+ var status string
+ var attempts int32
+ if err := pool.QueryRow(ctx, `SELECT status,attempts FROM workflow_dispatches WHERE id=$1`, dispatchID).Scan(&status, &attempts); err != nil {
+ t.Fatal(err)
+ }
+ if status != "pending" || attempts != 100 {
+ t.Fatalf("delayed dispatch = (%q,%d), want (pending,100)", status, attempts)
+ }
+
+ queries := sqlc.New(pool)
+ if err := queries.CancelWorkflowDispatch(ctx, sqlc.CancelWorkflowDispatchParams{
+ AggregateType: workflow.AggregateAnalysisRun,
+ AggregateID: aggregateID,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := pool.QueryRow(ctx, `SELECT status FROM workflow_dispatches WHERE id=$1`, dispatchID).Scan(&status); err != nil {
+ t.Fatal(err)
+ }
+ if status != "cancelled" {
+ t.Fatalf("cancelled dispatch status = %q", status)
+ }
+}
diff --git a/internal/workflow/dispatcher.go b/internal/workflow/dispatcher.go
new file mode 100644
index 0000000..62c6f5b
--- /dev/null
+++ b/internal/workflow/dispatcher.go
@@ -0,0 +1,160 @@
+package workflow
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "time"
+
+ biz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/google/uuid"
+)
+
+type Store interface {
+ Claim(context.Context, int32, time.Duration) ([]biz.Dispatch, error)
+ IsPending(context.Context, uuid.UUID) (bool, error)
+ Complete(context.Context, biz.Dispatch, string) error
+ Delay(context.Context, uuid.UUID, time.Time, string) error
+}
+
+type Launcher interface {
+ DispatchRepository(context.Context, uuid.UUID, uuid.UUID) (string, error)
+ DispatchAnalysis(context.Context, uuid.UUID, uuid.UUID) (string, error)
+ DispatchNotification(context.Context, uuid.UUID, uuid.UUID) (string, error)
+ DispatchRetention(context.Context, uuid.UUID, uuid.UUID) (string, error)
+ CancelWorkflow(context.Context, string) error
+}
+
+type Dispatcher struct {
+ store Store
+ launcher Launcher
+ pollInterval time.Duration
+ lease time.Duration
+ batchSize int32
+ now func() time.Time
+ logger *slog.Logger
+}
+
+func NewDispatcher(store Store, launcher Launcher, options ...Option) *Dispatcher {
+ dispatcher := &Dispatcher{
+ store: store, launcher: launcher,
+ pollInterval: time.Second, lease: time.Minute, batchSize: 20,
+ now: time.Now, logger: slog.Default(),
+ }
+ for _, option := range options {
+ option(dispatcher)
+ }
+
+ return dispatcher
+}
+
+func (d *Dispatcher) Run(ctx context.Context) error {
+ if err := d.RunOnce(ctx); err != nil {
+ return fmt.Errorf("initial workflow outbox dispatch: %w", err)
+ }
+
+ ticker := time.NewTicker(d.pollInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return nil
+ case <-ticker.C:
+ if err := d.RunOnce(ctx); err != nil {
+ d.logger.ErrorContext(ctx, "workflow outbox dispatch failed", "error", err)
+ }
+ }
+ }
+}
+
+func (d *Dispatcher) RunOnce(ctx context.Context) error {
+ items, err := d.store.Claim(ctx, d.batchSize, d.lease)
+ if err != nil {
+ return fmt.Errorf("claim workflow dispatches: %w", err)
+ }
+
+ for _, item := range items {
+ if err := d.dispatch(ctx, item); err != nil {
+ d.logger.WarnContext(ctx, "workflow dispatch delayed", "dispatch_id", item.ID, "workflow", item.WorkflowName, "attempt", item.Attempts, "error", err)
+ }
+ }
+
+ return nil
+}
+
+func (d *Dispatcher) dispatch(ctx context.Context, item biz.Dispatch) error {
+ pending, err := d.store.IsPending(ctx, item.ID)
+ if err != nil {
+ return err
+ }
+ if !pending {
+ return nil
+ }
+
+ var payload biz.Payload
+ if err := json.Unmarshal(item.Payload, &payload); err != nil {
+ return d.delay(ctx, item, fmt.Errorf("decode workflow payload: %w", err))
+ }
+ if payload.AggregateID == uuid.Nil || payload.AggregateID != item.AggregateID {
+ return d.delay(ctx, item, errors.New("workflow payload identifiers are invalid"))
+ }
+
+ var workflowRunID string
+ switch item.WorkflowName {
+ case biz.WorkflowRepositoryOperation:
+ if payload.RepositoryID == uuid.Nil {
+ return d.delay(ctx, item, errors.New("repository workflow has no repository ID"))
+ }
+ workflowRunID, err = d.launcher.DispatchRepository(ctx, payload.AggregateID, payload.RepositoryID)
+ case biz.WorkflowAnalysisRun:
+ if payload.RepositoryID == uuid.Nil {
+ return d.delay(ctx, item, errors.New("analysis workflow has no repository ID"))
+ }
+ workflowRunID, err = d.launcher.DispatchAnalysis(ctx, payload.AggregateID, payload.RepositoryID)
+ case biz.WorkflowNotificationDelivery:
+ if payload.ChannelID == uuid.Nil {
+ return d.delay(ctx, item, errors.New("notification workflow has no channel ID"))
+ }
+ workflowRunID, err = d.launcher.DispatchNotification(ctx, payload.AggregateID, payload.ChannelID)
+ case biz.WorkflowRetentionCleanup:
+ if payload.WorkspaceID == uuid.Nil {
+ return d.delay(ctx, item, errors.New("retention workflow has no workspace ID"))
+ }
+ workflowRunID, err = d.launcher.DispatchRetention(ctx, payload.AggregateID, payload.WorkspaceID)
+ default:
+ err = fmt.Errorf("unsupported workflow %q", item.WorkflowName)
+ }
+ if err != nil {
+ return d.delay(ctx, item, err)
+ }
+ if workflowRunID == "" {
+ return d.delay(ctx, item, errors.New("hatchet returned an empty workflow run ID"))
+ }
+
+ err = d.store.Complete(ctx, item, workflowRunID)
+ if errors.Is(err, biz.ErrDispatchNotPending) {
+ if cancelErr := d.launcher.CancelWorkflow(ctx, workflowRunID); cancelErr != nil {
+ return fmt.Errorf("cancel workflow for non-pending dispatch: %w", cancelErr)
+ }
+
+ return nil
+ }
+
+ return err
+}
+
+func (d *Dispatcher) delay(ctx context.Context, item biz.Dispatch, cause error) error {
+ message := cause.Error()
+ if len(message) > 1000 {
+ message = message[:1000]
+ }
+ attempt := max(item.Attempts, 1)
+ backoff := time.Second << min(attempt-1, 6)
+ if err := d.store.Delay(ctx, item.ID, d.now().Add(backoff), message); err != nil {
+ return errors.Join(cause, err)
+ }
+
+ return cause
+}
diff --git a/internal/workflow/dispatcher_test.go b/internal/workflow/dispatcher_test.go
new file mode 100644
index 0000000..d7748b9
--- /dev/null
+++ b/internal/workflow/dispatcher_test.go
@@ -0,0 +1,229 @@
+package workflow
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ biz "github.com/fuchencong/mooncode/internal/workflow/biz"
+ "github.com/google/uuid"
+)
+
+type dispatchStore struct {
+ items []biz.Dispatch
+ pending bool
+ completed string
+ delays []dispatchDelay
+ claimErr error
+ completeErr error
+}
+
+type dispatchDelay struct {
+ availableAt time.Time
+ message string
+}
+
+func (s *dispatchStore) Claim(context.Context, int32, time.Duration) ([]biz.Dispatch, error) {
+ return s.items, s.claimErr
+}
+
+func TestDispatcherReturnsInitialClaimFailure(t *testing.T) {
+ want := errors.New("database unavailable")
+ dispatcher := NewDispatcher(&dispatchStore{claimErr: want}, &launcher{})
+
+ err := dispatcher.Run(context.Background())
+ if !errors.Is(err, want) {
+ t.Fatalf("Run() error = %v, want %v", err, want)
+ }
+}
+func (s *dispatchStore) IsPending(context.Context, uuid.UUID) (bool, error) { return s.pending, nil }
+func (s *dispatchStore) Complete(_ context.Context, _ biz.Dispatch, runID string) error {
+ s.completed = runID
+ return s.completeErr
+}
+func (s *dispatchStore) Delay(_ context.Context, _ uuid.UUID, availableAt time.Time, message string) error {
+ s.delays = append(s.delays, dispatchDelay{availableAt: availableAt, message: message})
+ return nil
+}
+
+type launcher struct {
+ notificationID uuid.UUID
+ channelID uuid.UUID
+ cleanupID uuid.UUID
+ workspaceID uuid.UUID
+ dispatches int
+ cancelled string
+ err error
+}
+
+func (l *launcher) DispatchRepository(context.Context, uuid.UUID, uuid.UUID) (string, error) {
+ l.dispatches++
+ return "repository-run", l.err
+}
+func (l *launcher) DispatchAnalysis(context.Context, uuid.UUID, uuid.UUID) (string, error) {
+ l.dispatches++
+ return "analysis-run", l.err
+}
+func (l *launcher) DispatchNotification(_ context.Context, notificationID, channelID uuid.UUID) (string, error) {
+ l.dispatches++
+ l.notificationID, l.channelID = notificationID, channelID
+ return "notification-run", l.err
+}
+func (l *launcher) DispatchRetention(_ context.Context, cleanupID, workspaceID uuid.UUID) (string, error) {
+ l.dispatches++
+ l.cleanupID, l.workspaceID = cleanupID, workspaceID
+ return "retention-run", l.err
+}
+func (l *launcher) CancelWorkflow(_ context.Context, workflowRunID string) error {
+ l.cancelled = workflowRunID
+ return nil
+}
+
+func TestDispatcherDispatchesNotification(t *testing.T) {
+ notificationID, repositoryID, channelID := uuid.New(), uuid.New(), uuid.New()
+ dispatch, err := biz.NewDispatch(biz.AggregateNotification, notificationID, biz.WorkflowNotificationDelivery, biz.Payload{AggregateID: notificationID, RepositoryID: repositoryID, ChannelID: channelID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ dispatch.Attempts = 1
+ store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true}
+ launcher := &launcher{}
+
+ if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if launcher.notificationID != notificationID || launcher.channelID != channelID {
+ t.Fatalf("unexpected notification launch: %s %s", launcher.notificationID, launcher.channelID)
+ }
+ if store.completed != "notification-run" || len(store.delays) != 0 {
+ t.Fatalf("unexpected completion state: completed=%q delays=%d", store.completed, len(store.delays))
+ }
+}
+
+func TestDispatcherDelaysFailedLaunch(t *testing.T) {
+ runID, repositoryID := uuid.New(), uuid.New()
+ dispatch, err := biz.NewDispatch(biz.AggregateAnalysisRun, runID, biz.WorkflowAnalysisRun, biz.Payload{AggregateID: runID, RepositoryID: repositoryID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ dispatch.Attempts = 1
+ store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true}
+ launcher := &launcher{err: errors.New("Hatchet unavailable")}
+
+ if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.delays) != 1 || store.completed != "" {
+ t.Fatalf("unexpected retry state: completed=%q delays=%d", store.completed, len(store.delays))
+ }
+}
+
+func TestDispatcherKeepsHighAttemptFailurePendingWithCappedBackoff(t *testing.T) {
+ runID, repositoryID := uuid.New(), uuid.New()
+ dispatch, err := biz.NewDispatch(
+ biz.AggregateAnalysisRun,
+ runID,
+ biz.WorkflowAnalysisRun,
+ biz.Payload{AggregateID: runID, RepositoryID: repositoryID},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ dispatch.Attempts = 100
+
+ now := time.Date(2026, time.July, 30, 12, 0, 0, 0, time.UTC)
+ store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true}
+ launcher := &launcher{err: errors.New("Hatchet unavailable")}
+
+ if err := NewDispatcher(store, launcher, WithClock(func() time.Time { return now })).RunOnce(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(store.delays) != 1 {
+ t.Fatalf("delay count = %d, want 1", len(store.delays))
+ }
+ if want := now.Add(64 * time.Second); !store.delays[0].availableAt.Equal(want) {
+ t.Fatalf("availableAt = %s, want %s", store.delays[0].availableAt, want)
+ }
+ if store.completed != "" || launcher.dispatches != 1 {
+ t.Fatalf("unexpected retry state: completed=%q dispatches=%d", store.completed, launcher.dispatches)
+ }
+}
+
+func TestDispatcherDoesNotLaunchCancelledDispatch(t *testing.T) {
+ runID, repositoryID := uuid.New(), uuid.New()
+ dispatch, err := biz.NewDispatch(
+ biz.AggregateAnalysisRun,
+ runID,
+ biz.WorkflowAnalysisRun,
+ biz.Payload{AggregateID: runID, RepositoryID: repositoryID},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ dispatch.Attempts = 1
+
+ store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: false}
+ launcher := &launcher{}
+
+ if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if launcher.dispatches != 0 || store.completed != "" || len(store.delays) != 0 {
+ t.Fatalf("cancelled dispatch changed: dispatches=%d completed=%q delays=%d", launcher.dispatches, store.completed, len(store.delays))
+ }
+}
+
+func TestDispatcherCancelsLaunchedWorkflowWhenDispatchWasCancelledDuringLaunch(t *testing.T) {
+ runID, repositoryID := uuid.New(), uuid.New()
+ dispatch, err := biz.NewDispatch(
+ biz.AggregateAnalysisRun,
+ runID,
+ biz.WorkflowAnalysisRun,
+ biz.Payload{AggregateID: runID, RepositoryID: repositoryID},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ dispatch.Attempts = 1
+
+ store := &dispatchStore{
+ items: []biz.Dispatch{dispatch},
+ pending: true,
+ completeErr: biz.ErrDispatchNotPending,
+ }
+ launcher := &launcher{}
+
+ if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if launcher.dispatches != 1 || launcher.cancelled != "analysis-run" || len(store.delays) != 0 {
+ t.Fatalf("launch cancellation mismatch: dispatches=%d cancelled=%q delays=%d", launcher.dispatches, launcher.cancelled, len(store.delays))
+ }
+}
+
+func TestDispatcherDispatchesWorkspaceRetention(t *testing.T) {
+ cleanupID, workspaceID := uuid.New(), uuid.New()
+ dispatch, err := biz.NewDispatch(
+ biz.AggregateRetentionCleanup,
+ cleanupID,
+ biz.WorkflowRetentionCleanup,
+ biz.Payload{AggregateID: cleanupID, WorkspaceID: workspaceID},
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ dispatch.Attempts = 1
+ store := &dispatchStore{items: []biz.Dispatch{dispatch}, pending: true}
+ launcher := &launcher{}
+
+ if err := NewDispatcher(store, launcher).RunOnce(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if launcher.cleanupID != cleanupID || launcher.workspaceID != workspaceID {
+ t.Fatalf("unexpected retention launch: %s %s", launcher.cleanupID, launcher.workspaceID)
+ }
+ if store.completed != "retention-run" || len(store.delays) != 0 {
+ t.Fatalf("unexpected completion state: completed=%q delays=%d", store.completed, len(store.delays))
+ }
+}
diff --git a/internal/workflow/options.go b/internal/workflow/options.go
new file mode 100644
index 0000000..a94e3ac
--- /dev/null
+++ b/internal/workflow/options.go
@@ -0,0 +1,28 @@
+package workflow
+
+import (
+ "log/slog"
+ "time"
+)
+
+type Option func(*Dispatcher)
+
+func WithPollInterval(value time.Duration) Option {
+ return func(dispatcher *Dispatcher) { dispatcher.pollInterval = value }
+}
+
+func WithLease(value time.Duration) Option {
+ return func(dispatcher *Dispatcher) { dispatcher.lease = value }
+}
+
+func WithBatchSize(value int32) Option {
+ return func(dispatcher *Dispatcher) { dispatcher.batchSize = value }
+}
+
+func WithClock(now func() time.Time) Option {
+ return func(dispatcher *Dispatcher) { dispatcher.now = now }
+}
+
+func WithLogger(logger *slog.Logger) Option {
+ return func(dispatcher *Dispatcher) { dispatcher.logger = logger }
+}
diff --git a/migrations/embed.go b/migrations/embed.go
index 42f33a8..25ce955 100644
--- a/migrations/embed.go
+++ b/migrations/embed.go
@@ -1,9 +1,8 @@
-// Package migrations exposes MoonCode's forward-only PostgreSQL migrations.
package migrations
import "embed"
-// PostgreSQL contains the migration files rooted at postgres/.
+// Postgres contains all MoonCode-owned schema migrations.
//
//go:embed postgres/*.sql
-var PostgreSQL embed.FS
+var Postgres embed.FS
diff --git a/migrations/postgres/000001_initial.down.sql b/migrations/postgres/000001_initial.down.sql
new file mode 100644
index 0000000..a0e57e1
--- /dev/null
+++ b/migrations/postgres/000001_initial.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS audit_events, conversation_bindings, channel_identity_links, channel_external_identities, messages, conversations, notifications, channel_subscriptions, channels, retention_cleanups, retention_cleanup_schedules, workflow_dispatches, analysis_reports, analysis_runs, analysis_profile_versions, analysis_profiles, repository_operations, commit_snapshots, repository_source_keys, repositories, provider_connections, workspace_invitations, workspace_members, workspaces, oauth_identities, users CASCADE;
diff --git a/migrations/postgres/000001_initial.up.sql b/migrations/postgres/000001_initial.up.sql
index 5e9ed23..1d411f7 100644
--- a/migrations/postgres/000001_initial.up.sql
+++ b/migrations/postgres/000001_initial.up.sql
@@ -1,295 +1,493 @@
CREATE TABLE users (
id uuid PRIMARY KEY,
- issuer text NOT NULL,
- external_subject text NOT NULL,
- username text NOT NULL,
- email text NOT NULL DEFAULT '',
display_name text NOT NULL DEFAULT '',
- status text NOT NULL DEFAULT 'pending'
- CHECK (status IN ('pending', 'active', 'suspended', 'deleted')),
- activated_at timestamptz,
- suspended_at timestamptz,
- last_seen_at timestamptz,
- terms_version text NOT NULL DEFAULT '',
- privacy_version text NOT NULL DEFAULT '',
- agreements_accepted_at timestamptz,
- deleted_at timestamptz,
+ username text NOT NULL DEFAULT '',
+ email text NOT NULL DEFAULT '',
+ status text NOT NULL CHECK (status IN ('pending', 'active', 'suspended', 'deleted')),
+ accepted_terms_at timestamptz,
+ accepted_privacy_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE oauth_identities (
+ issuer text NOT NULL,
+ subject text NOT NULL,
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
- UNIQUE (issuer, external_subject)
+ PRIMARY KEY (issuer, subject)
);
CREATE TABLE workspaces (
id uuid PRIMARY KEY,
name text NOT NULL,
slug text NOT NULL UNIQUE,
- kind text NOT NULL CHECK (kind IN ('personal', 'team')),
+ report_retention_days integer NOT NULL DEFAULT 90
+ CHECK (report_retention_days BETWEEN 1 AND 3650),
created_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now(),
- deleted_at timestamptz
+ updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE workspace_members (
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- user_id uuid NOT NULL REFERENCES users(id),
- role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ role text NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, user_id)
);
-CREATE INDEX workspace_members_user_idx ON workspace_members(user_id);
CREATE TABLE workspace_invitations (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- email_normalized text NOT NULL,
- role text NOT NULL CHECK (role IN ('admin', 'member', 'viewer')),
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ email text NOT NULL,
token_hash bytea NOT NULL UNIQUE,
- invited_by uuid NOT NULL REFERENCES users(id),
+ created_by uuid NOT NULL REFERENCES users(id),
expires_at timestamptz NOT NULL,
accepted_at timestamptz,
- accepted_by uuid REFERENCES users(id),
revoked_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE provider_connections (
+ id uuid PRIMARY KEY,
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitlab')),
+ base_url text NOT NULL,
+ token_ciphertext bytea NOT NULL,
+ token_nonce bytea NOT NULL,
+ key_version integer NOT NULL DEFAULT 1,
+ credential_version bigint NOT NULL DEFAULT 1,
+ provider_account_id text,
+ login text,
+ display_name text,
+ scopes text[] NOT NULL DEFAULT '{}',
+ is_default boolean NOT NULL DEFAULT false,
+ status text NOT NULL CHECK (status IN ('active', 'invalid', 'revoked')),
+ last_validated_at timestamptz,
+ last_used_at timestamptz,
+ last_error_code text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-CREATE INDEX workspace_invitations_workspace_idx
- ON workspace_invitations(workspace_id, created_at DESC);
-CREATE INDEX workspace_invitations_active_email_idx
- ON workspace_invitations(workspace_id, email_normalized)
- WHERE accepted_at IS NULL AND revoked_at IS NULL;
-CREATE TABLE account_audit_logs (
+CREATE UNIQUE INDEX provider_connections_active_identity_idx
+ ON provider_connections (user_id, provider_type, base_url)
+ WHERE status <> 'revoked';
+
+CREATE UNIQUE INDEX provider_connections_default_idx
+ ON provider_connections (user_id, provider_type)
+ WHERE is_default AND status <> 'revoked';
+
+CREATE TABLE repositories (
id uuid PRIMARY KEY,
- user_id uuid NOT NULL REFERENCES users(id),
- actor_user_id uuid REFERENCES users(id),
- action text NOT NULL,
- result text NOT NULL CHECK (result IN ('success', 'failure')),
- provider text NOT NULL DEFAULT '',
- request_id text NOT NULL DEFAULT '',
- metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
- occurred_at timestamptz NOT NULL DEFAULT now()
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ provider_type text NOT NULL CHECK (provider_type IN ('github', 'gitlab')),
+ name text NOT NULL,
+ remote_url text NOT NULL,
+ normalized_url text NOT NULL,
+ configured_ref text NOT NULL,
+ config_version bigint NOT NULL DEFAULT 1,
+ git_path text NOT NULL UNIQUE,
+ status text NOT NULL CHECK (status IN ('provisioning', 'syncing', 'ready', 'failed', 'deleting', 'deleted')),
+ current_snapshot_id uuid,
+ mirror_size_bytes bigint NOT NULL DEFAULT 0 CHECK (mirror_size_bytes >= 0),
+ last_sync_at timestamptz,
+ last_error_code text,
+ last_error_message text,
+ archived_at timestamptz,
+ deleted_at timestamptz,
+ created_by uuid NOT NULL REFERENCES users(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (workspace_id, normalized_url)
+);
+
+CREATE TABLE repository_source_keys (
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ normalized_url text NOT NULL CHECK (btrim(normalized_url) <> ''),
+ repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (workspace_id, normalized_url),
+ UNIQUE (repository_id, normalized_url)
);
-CREATE INDEX account_audit_logs_user_idx
- ON account_audit_logs(user_id, occurred_at DESC, id DESC);
-CREATE TABLE secrets (
+CREATE TABLE repository_operations (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- resource_type text NOT NULL,
- resource_id uuid NOT NULL,
- ciphertext bytea NOT NULL,
- nonce bytea NOT NULL,
- wrapped_key bytea NOT NULL,
- wrapped_key_nonce bytea NOT NULL,
- key_version integer NOT NULL,
+ repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ actor_user_id uuid NOT NULL REFERENCES users(id),
+ provider_connection_id uuid REFERENCES provider_connections(id) ON DELETE SET NULL,
+ credential_version bigint,
+ repository_version bigint NOT NULL,
+ kind text NOT NULL CHECK (kind IN ('provision', 'refresh', 'update', 'purge')),
+ requested_provider_type text NOT NULL CHECK (requested_provider_type IN ('github', 'gitlab')),
+ requested_remote_url text NOT NULL CHECK (btrim(requested_remote_url) <> ''),
+ requested_normalized_url text NOT NULL CHECK (btrim(requested_normalized_url) <> ''),
+ requested_ref text NOT NULL CHECK (btrim(requested_ref) <> ''),
+ previous_provider_type text NOT NULL CHECK (previous_provider_type IN ('github', 'gitlab')),
+ previous_remote_url text NOT NULL CHECK (btrim(previous_remote_url) <> ''),
+ previous_normalized_url text NOT NULL CHECK (btrim(previous_normalized_url) <> ''),
+ previous_ref text NOT NULL CHECK (btrim(previous_ref) <> ''),
+ status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
+ outcome text CHECK (outcome IN ('changed', 'no_change')),
+ resolved_commit_sha text,
+ snapshot_id uuid,
+ workflow_run_id text,
+ error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now()
+ started_at timestamptz,
+ finished_at timestamptz
);
-CREATE INDEX secrets_workspace_idx ON secrets(workspace_id);
-CREATE TABLE scm_connections (
+CREATE TABLE commit_snapshots (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- type text NOT NULL,
- name text NOT NULL,
- base_url text NOT NULL,
- auth_type text NOT NULL,
- secret_ref uuid REFERENCES secrets(id),
+ repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ commit_sha text NOT NULL,
+ source_ref text NOT NULL,
+ git_ref text NOT NULL UNIQUE,
+ author_name text,
+ authored_at timestamptz,
+ title text,
+ source_state text NOT NULL CHECK (source_state IN ('available', 'purging', 'purged')),
+ purge_cleanup_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now(),
- deleted_at timestamptz,
- UNIQUE (workspace_id, name)
+ UNIQUE (repository_id, commit_sha)
);
-CREATE INDEX scm_connections_workspace_idx ON scm_connections(workspace_id) WHERE deleted_at IS NULL;
-CREATE TABLE repositories (
+ALTER TABLE repositories
+ ADD CONSTRAINT repositories_current_snapshot_fk
+ FOREIGN KEY (current_snapshot_id) REFERENCES commit_snapshots(id) ON DELETE SET NULL;
+
+ALTER TABLE repository_operations
+ ADD CONSTRAINT repository_operations_snapshot_fk
+ FOREIGN KEY (snapshot_id) REFERENCES commit_snapshots(id) ON DELETE SET NULL;
+
+CREATE TABLE analysis_profiles (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- connection_id uuid REFERENCES scm_connections(id),
- name text NOT NULL,
- clone_url text NOT NULL,
- normalized_url text NOT NULL,
- ref text NOT NULL DEFAULT '',
- current_commit_sha text NOT NULL DEFAULT '',
- state text NOT NULL CHECK (state IN ('pending', 'ready', 'syncing', 'failed', 'deleting')),
- last_error_code text NOT NULL DEFAULT '',
- last_error_message text NOT NULL DEFAULT '',
- synced_at timestamptz,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ name text NOT NULL CHECK (btrim(name) <> ''),
+ current_version integer NOT NULL CHECK (current_version > 0),
+ archived_at timestamptz,
+ created_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
- deleted_at timestamptz,
- UNIQUE (workspace_id, normalized_url)
+ UNIQUE (workspace_id, id)
);
-CREATE INDEX repositories_workspace_idx ON repositories(workspace_id) WHERE deleted_at IS NULL;
-CREATE TABLE jobs (
+CREATE UNIQUE INDEX analysis_profiles_workspace_name_key
+ ON analysis_profiles (workspace_id, lower(name))
+ WHERE archived_at IS NULL;
+
+CREATE TABLE analysis_profile_versions (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- type text NOT NULL,
- payload jsonb NOT NULL,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ profile_id uuid NOT NULL,
+ version integer NOT NULL CHECK (version > 0),
+ dimension_key text NOT NULL CHECK (dimension_key = 'code_scale'),
+ definition jsonb NOT NULL CHECK (jsonb_typeof(definition) = 'object'),
+ created_by uuid NOT NULL REFERENCES users(id),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (workspace_id, profile_id, version),
+ FOREIGN KEY (workspace_id, profile_id) REFERENCES analysis_profiles(workspace_id, id) ON DELETE CASCADE
+);
+
+CREATE TABLE analysis_runs (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ snapshot_id uuid NOT NULL REFERENCES commit_snapshots(id),
+ commit_sha text NOT NULL,
+ requested_by uuid NOT NULL REFERENCES users(id),
+ dimension_key text NOT NULL,
+ profile_id uuid NOT NULL,
+ profile_version text NOT NULL,
+ profile_snapshot jsonb NOT NULL CHECK (jsonb_typeof(profile_snapshot) = 'object'),
+ analyzer_version text NOT NULL,
+ idempotency_key text NOT NULL CHECK (idempotency_key ~ '^[0-9a-f]{64}$'),
+ attempt integer NOT NULL CHECK (attempt > 0),
+ rerun_of uuid REFERENCES analysis_runs(id) ON DELETE SET NULL,
status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
- idempotency_key text NOT NULL,
- attempt integer NOT NULL DEFAULT 0,
- max_attempts integer NOT NULL DEFAULT 5,
- lease_owner text,
- lease_until timestamptz,
- fencing_token bigint NOT NULL DEFAULT 0,
- run_after timestamptz NOT NULL DEFAULT now(),
- trace_context jsonb NOT NULL DEFAULT '{}'::jsonb,
- last_error_code text NOT NULL DEFAULT '',
- last_error_message text NOT NULL DEFAULT '',
+ stage text NOT NULL DEFAULT 'queued' CHECK (stage IN ('queued', 'prepare', 'authorize', 'checkout', 'analyze', 'persist', 'workflow', 'complete', 'cancelled')),
+ report_id uuid,
+ workflow_run_id text,
+ failed_stage text,
+ error_code text,
+ error_message text,
+ retryable boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz,
finished_at timestamptz,
- updated_at timestamptz NOT NULL DEFAULT now(),
- UNIQUE (type, idempotency_key)
+ CONSTRAINT analysis_runs_failure_fields_check CHECK (
+ (status = 'failed' AND failed_stage IS NOT NULL AND error_code IS NOT NULL)
+ OR (status <> 'failed' AND failed_stage IS NULL AND error_code IS NULL)
+ ),
+ CONSTRAINT analysis_runs_retryable_status_check CHECK (
+ NOT retryable OR status IN ('failed', 'cancelled')
+ ),
+ CONSTRAINT analysis_runs_idempotency_attempt_key UNIQUE (workspace_id, idempotency_key, attempt),
+ FOREIGN KEY (workspace_id, profile_id) REFERENCES analysis_profiles(workspace_id, id)
);
-CREATE INDEX jobs_claim_idx ON jobs(type, status, run_after, created_at);
-CREATE INDEX jobs_workspace_idx ON jobs(workspace_id, created_at DESC);
-CREATE UNIQUE INDEX jobs_active_repository_sync_idx
- ON jobs ((payload->>'repositoryId'))
- WHERE type = 'repository.sync' AND status IN ('queued', 'running');
-
-CREATE TABLE repository_sync_controls (
- repository_id uuid PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- next_allowed_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now()
+
+CREATE TABLE analysis_reports (
+ id uuid PRIMARY KEY,
+ analysis_run_id uuid NOT NULL UNIQUE REFERENCES analysis_runs(id) ON DELETE CASCADE,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ snapshot_id uuid NOT NULL REFERENCES commit_snapshots(id),
+ commit_sha text NOT NULL,
+ source_ref text NOT NULL,
+ commit_author_name text,
+ commit_authored_at timestamptz,
+ commit_title text,
+ dimension_key text NOT NULL,
+ profile_id uuid NOT NULL,
+ profile_version text NOT NULL,
+ profile_snapshot jsonb NOT NULL CHECK (jsonb_typeof(profile_snapshot) = 'object'),
+ analyzer_version text NOT NULL,
+ execution_environment text NOT NULL,
+ started_at timestamptz NOT NULL,
+ finished_at timestamptz NOT NULL,
+ duration_ms bigint NOT NULL CHECK (duration_ms >= 0),
+ result jsonb NOT NULL,
+ raw_artifact jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ FOREIGN KEY (workspace_id, profile_id) REFERENCES analysis_profiles(workspace_id, id)
);
-CREATE INDEX repository_sync_controls_workspace_idx
- ON repository_sync_controls(workspace_id, next_allowed_at);
-CREATE TABLE channel_instances (
+CREATE INDEX analysis_reports_workspace_idx
+ ON analysis_reports (workspace_id, created_at DESC);
+
+ALTER TABLE analysis_runs
+ ADD CONSTRAINT analysis_runs_report_fk
+ FOREIGN KEY (report_id) REFERENCES analysis_reports(id) ON DELETE SET NULL;
+
+CREATE TABLE workflow_dispatches (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- type text NOT NULL,
+ aggregate_type text NOT NULL,
+ aggregate_id uuid NOT NULL,
+ workflow_name text NOT NULL,
+ payload jsonb NOT NULL,
+ status text NOT NULL CHECK (status IN ('pending', 'dispatched', 'cancelled')),
+ attempts integer NOT NULL DEFAULT 0,
+ available_at timestamptz NOT NULL DEFAULT now(),
+ last_error text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ dispatched_at timestamptz
+);
+
+CREATE INDEX workflow_dispatches_pending_idx
+ ON workflow_dispatches (available_at, created_at)
+ WHERE status = 'pending';
+
+CREATE TABLE retention_cleanup_schedules (
+ workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
+ next_run_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ last_run_at timestamptz,
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp()
+);
+
+CREATE INDEX retention_cleanup_schedules_due_idx
+ ON retention_cleanup_schedules (next_run_at);
+
+CREATE TABLE retention_cleanups (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ scheduled_for timestamptz NOT NULL,
+ retention_days integer NOT NULL CHECK (retention_days BETWEEN 1 AND 3650),
+ status text NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')),
+ workflow_run_id text,
+ deleted_run_count integer NOT NULL DEFAULT 0 CHECK (deleted_run_count >= 0),
+ purged_snapshot_count integer NOT NULL DEFAULT 0 CHECK (purged_snapshot_count >= 0),
+ requeued_repository_count integer NOT NULL DEFAULT 0 CHECK (requeued_repository_count >= 0),
+ error_message text,
+ created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
+ started_at timestamptz,
+ finished_at timestamptz,
+ UNIQUE (workspace_id, scheduled_for)
+);
+
+ALTER TABLE commit_snapshots
+ ADD CONSTRAINT commit_snapshots_purge_cleanup_fk
+ FOREIGN KEY (purge_cleanup_id) REFERENCES retention_cleanups(id) ON DELETE SET NULL;
+
+CREATE INDEX retention_cleanups_workspace_created_idx
+ ON retention_cleanups (workspace_id, created_at DESC, id DESC);
+
+CREATE TABLE channels (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ type text NOT NULL CHECK (type IN ('feishu', 'dingtalk')),
name text NOT NULL,
enabled boolean NOT NULL DEFAULT false,
- config jsonb NOT NULL DEFAULT '{}'::jsonb,
- secret_ref uuid REFERENCES secrets(id),
+ runtime_status text NOT NULL DEFAULT 'disabled' CHECK (runtime_status IN ('disabled', 'starting', 'connected', 'error')),
+ secret_ciphertext bytea,
+ secret_nonce bytea,
+ key_version integer,
config_version bigint NOT NULL DEFAULT 1,
+ config jsonb NOT NULL DEFAULT '{}',
+ last_connected_at timestamptz,
+ last_error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
- deleted_at timestamptz,
- UNIQUE (workspace_id, name)
+ UNIQUE (workspace_id, id)
);
-CREATE INDEX channel_instances_workspace_idx ON channel_instances(workspace_id) WHERE deleted_at IS NULL;
-CREATE TABLE channel_leases (
- channel_instance_id uuid PRIMARY KEY REFERENCES channel_instances(id) ON DELETE CASCADE,
- owner text NOT NULL,
- lease_until timestamptz NOT NULL,
- fencing_token bigint NOT NULL,
- updated_at timestamptz NOT NULL DEFAULT now()
+CREATE TABLE channel_subscriptions (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ channel_id uuid NOT NULL,
+ event_type text NOT NULL CHECK (event_type IN ('analysis.started', 'analysis.succeeded', 'analysis.failed')),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (workspace_id, channel_id, event_type),
+ FOREIGN KEY (workspace_id, channel_id) REFERENCES channels(workspace_id, id) ON DELETE CASCADE
);
-CREATE TABLE channel_runtime_status (
- channel_instance_id uuid PRIMARY KEY REFERENCES channel_instances(id) ON DELETE CASCADE,
- state text NOT NULL,
- backend_instance_id text NOT NULL DEFAULT '',
- fencing_token bigint NOT NULL DEFAULT 0,
- last_connected_at timestamptz,
- last_error_code text NOT NULL DEFAULT '',
- last_error_message varchar(2048) NOT NULL DEFAULT '',
- updated_at timestamptz NOT NULL DEFAULT now()
+CREATE TABLE notifications (
+ id uuid PRIMARY KEY,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ analysis_run_id uuid NOT NULL REFERENCES analysis_runs(id) ON DELETE CASCADE,
+ channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ event_type text NOT NULL CHECK (event_type IN ('analysis.started', 'analysis.succeeded', 'analysis.failed')),
+ status text NOT NULL CHECK (status IN ('queued', 'running', 'delivered', 'failed', 'cancelled')),
+ workflow_run_id text,
+ error_message text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ started_at timestamptz,
+ finished_at timestamptz,
+ UNIQUE (analysis_run_id, channel_id, event_type)
);
-CREATE TABLE im_conversations (
+CREATE INDEX notifications_channel_idx
+ ON notifications (channel_id, created_at DESC);
+
+CREATE TABLE conversations (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- channel_instance_id uuid NOT NULL REFERENCES channel_instances(id),
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
external_id text NOT NULL,
type text NOT NULL,
title text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now(),
- UNIQUE (channel_instance_id, external_id)
+ UNIQUE (channel_id, external_id)
);
-CREATE INDEX im_conversations_workspace_idx ON im_conversations(workspace_id, updated_at DESC);
-CREATE TABLE im_senders (
+CREATE TABLE messages (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- channel_type text NOT NULL,
- canonical_id text NOT NULL,
- display_name text NOT NULL DEFAULT '',
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ conversation_id uuid NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
+ external_id text NOT NULL,
+ sender_canonical_id text NOT NULL,
+ sender_display_name text NOT NULL DEFAULT '',
+ content jsonb NOT NULL,
+ occurred_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
- updated_at timestamptz NOT NULL DEFAULT now(),
- UNIQUE (workspace_id, canonical_id)
+ UNIQUE (channel_id, external_id)
);
-CREATE TABLE im_messages (
+CREATE TABLE channel_external_identities (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- channel_instance_id uuid NOT NULL REFERENCES channel_instances(id),
- conversation_id uuid NOT NULL REFERENCES im_conversations(id),
- sender_id uuid NOT NULL REFERENCES im_senders(id),
- external_message_id text NOT NULL,
- content jsonb NOT NULL,
- occurred_at timestamptz NOT NULL,
- received_at timestamptz NOT NULL DEFAULT now(),
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ sender_canonical_id text NOT NULL,
+ user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
- UNIQUE (channel_instance_id, external_message_id)
-);
-CREATE INDEX im_messages_workspace_idx ON im_messages(workspace_id, occurred_at DESC, id DESC);
-
-CREATE TABLE inbox_events (
- channel_instance_id uuid NOT NULL REFERENCES channel_instances(id),
- external_event_id text NOT NULL,
- payload_hash text NOT NULL,
- received_at timestamptz NOT NULL DEFAULT now(),
- PRIMARY KEY (channel_instance_id, external_event_id)
+ UNIQUE (channel_id, sender_canonical_id)
);
-CREATE TABLE outbox_events (
+CREATE TABLE channel_identity_links (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- aggregate text NOT NULL,
- aggregate_id uuid NOT NULL,
- event_type text NOT NULL,
- payload jsonb NOT NULL,
- trace_context jsonb NOT NULL DEFAULT '{}'::jsonb,
- lease_owner text,
- lease_until timestamptz,
- attempt integer NOT NULL DEFAULT 0,
- next_attempt_at timestamptz NOT NULL DEFAULT now(),
- last_error_code text NOT NULL DEFAULT '',
- last_error_message text NOT NULL DEFAULT '',
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ channel_id uuid NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
+ channel_version bigint NOT NULL,
+ sender_canonical_id text NOT NULL,
+ token_hash bytea NOT NULL UNIQUE,
+ expires_at timestamptz NOT NULL,
+ consumed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX channel_identity_links_sender_idx
+ ON channel_identity_links (channel_id, sender_canonical_id, created_at DESC);
+
+CREATE TABLE conversation_bindings (
+ conversation_id uuid PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
+ workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ repository_id uuid NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ bound_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
- published_at timestamptz
+ updated_at timestamptz NOT NULL DEFAULT now()
);
-CREATE INDEX outbox_events_unpublished_idx
- ON outbox_events(next_attempt_at, created_at)
- WHERE published_at IS NULL;
-CREATE TABLE audit_logs (
+CREATE TABLE audit_events (
id uuid PRIMARY KEY,
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- actor_user_id uuid REFERENCES users(id),
- action text NOT NULL,
- resource_type text NOT NULL,
- resource_id uuid,
- result text NOT NULL,
- metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
- occurred_at timestamptz NOT NULL DEFAULT now()
-);
-CREATE INDEX audit_logs_workspace_idx ON audit_logs(workspace_id, occurred_at DESC, id DESC);
-
-CREATE TABLE mutation_controls (
- workspace_id uuid NOT NULL REFERENCES workspaces(id),
- operation text NOT NULL,
- resource_key text NOT NULL,
- idempotency_key text NOT NULL,
- request_hash text NOT NULL,
+ workspace_id uuid REFERENCES workspaces(id) ON DELETE SET NULL,
+ actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
+ action text NOT NULL CHECK (action <> ''),
+ resource_type text NOT NULL CHECK (resource_type <> ''),
resource_id uuid,
- status text NOT NULL CHECK (status IN ('active', 'succeeded', 'failed')),
- active_until timestamptz NOT NULL,
- next_allowed_at timestamptz NOT NULL,
- updated_at timestamptz NOT NULL DEFAULT now(),
- PRIMARY KEY (workspace_id, operation, resource_key),
- UNIQUE (workspace_id, operation, idempotency_key)
+ metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(metadata) = 'object'),
+ created_at timestamptz NOT NULL DEFAULT now()
);
-CREATE INDEX mutation_controls_expiry_idx
- ON mutation_controls(status, active_until, next_allowed_at);
+
+CREATE INDEX audit_events_workspace_created_idx
+ ON audit_events (workspace_id, created_at DESC, id DESC)
+ WHERE workspace_id IS NOT NULL;
+
+CREATE INDEX audit_events_actor_created_idx
+ ON audit_events (actor_user_id, created_at DESC, id DESC)
+ WHERE actor_user_id IS NOT NULL;
+
+CREATE INDEX audit_events_resource_created_idx
+ ON audit_events (resource_type, resource_id, created_at DESC, id DESC)
+ WHERE resource_id IS NOT NULL;
+
+CREATE INDEX provider_connections_user_created_idx
+ ON provider_connections (user_id, created_at DESC, id DESC)
+ WHERE status <> 'revoked';
+
+CREATE INDEX workspace_members_workspace_created_idx
+ ON workspace_members (workspace_id, created_at DESC, user_id DESC);
+
+CREATE INDEX workspace_invitations_workspace_created_idx
+ ON workspace_invitations (workspace_id, created_at DESC, id DESC);
+
+CREATE INDEX repositories_workspace_created_idx
+ ON repositories (workspace_id, created_at DESC, id DESC)
+ WHERE status <> 'deleted';
+
+CREATE INDEX repository_operations_repository_created_idx
+ ON repository_operations (repository_id, created_at DESC, id DESC);
+
+CREATE INDEX commit_snapshots_repository_created_idx
+ ON commit_snapshots (repository_id, created_at DESC, id DESC);
+
+CREATE INDEX analysis_runs_repository_created_idx
+ ON analysis_runs (workspace_id, repository_id, created_at DESC, id DESC);
+
+CREATE INDEX analysis_runs_retention_idx
+ ON analysis_runs (workspace_id, finished_at, id)
+ WHERE status IN ('succeeded', 'failed', 'cancelled');
+
+CREATE INDEX analysis_profiles_workspace_created_idx
+ ON analysis_profiles (workspace_id, created_at DESC, id DESC)
+ WHERE archived_at IS NULL;
+
+CREATE INDEX channels_workspace_created_idx
+ ON channels (workspace_id, created_at DESC, id DESC);
+
+CREATE INDEX channel_subscriptions_workspace_event_idx
+ ON channel_subscriptions (workspace_id, event_type, channel_id);
+
+CREATE INDEX conversations_workspace_created_idx
+ ON conversations (workspace_id, created_at DESC, id DESC);
+
+CREATE INDEX messages_workspace_occurred_idx
+ ON messages (workspace_id, occurred_at DESC, id DESC);
+
+CREATE INDEX messages_channel_occurred_idx
+ ON messages (workspace_id, channel_id, occurred_at DESC, id DESC);
+
+CREATE INDEX messages_conversation_occurred_idx
+ ON messages (workspace_id, conversation_id, occurred_at DESC, id DESC);
diff --git a/mooncode.example.yaml b/mooncode.example.yaml
index 3eb3e62..7022781 100644
--- a/mooncode.example.yaml
+++ b/mooncode.example.yaml
@@ -1,105 +1,47 @@
-service:
- environment: development
+http:
+ address: ":8080"
+ max_body_bytes: 1048576
+ request_timeout: 30s
database:
- url: postgres://mooncode:mooncode@127.0.0.1:5432/mooncode?sslmode=disable
+ url: postgres://mooncode:change-me@127.0.0.1:5432/mooncode?sslmode=disable
+ migrate_on_start: true
max_connections: 20
- min_connections: 2
- connect_timeout: 10s
- health_interval: 5s
- auto_migrate: true
-
-identity:
- mode: oauth_subject_required
- issuer: https://github.com
- auth_url: http://auth.mooncode.localhost:3100
- gateway_token: mooncode-local-gateway-token-change-me
-registration:
- mode: invite_only
- terms_version: "2026-07-01"
- privacy_version: "2026-07-01"
- invitation_ttl: 168h
- create_personal_workspace: true
-
-secret_store:
- # Public development placeholder. Generate a distinct 32-byte base64 key
- # for every non-development deployment.
- master_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
+auth:
+ # Only trust these headers after a reverse proxy has stripped client values
+ # and copied Tinyauth's ForwardAuth response headers.
+ issuer: tinyauth
+ app_url: https://mooncode.example.com/app
+ logout_url: /api/user/logout
+ registration_mode: public
+ terms_version: "2026-07-30"
+ privacy_version: "2026-07-30"
+ trusted_proxy_cidrs:
+ - 10.0.0.2/32
+ csrf_key: REPLACE_WITH_BASE64_32_BYTE_KEY
+
+secrets:
+ key: REPLACE_WITH_A_DISTINCT_BASE64_32_BYTE_KEY
key_version: 1
-git:
- repository_directory: /var/lib/mooncode/repositories
- clone_timeout: 10m
- depth: 1
- sync_cooldown: 30s
- probe_cooldown: 15s
- # Admin-controlled allow list for private GitLab networks; empty is safest.
- allowed_private_cidrs: []
-
-jobs:
- poll_interval: 1s
- lease_duration: 30s
- renew_interval: 10s
- retry_base_delay: 2s
-
-outbox:
- poll_interval: 1s
- batch_size: 100
- lease_duration: 30s
- publish_timeout: 10s
- retry_base_delay: 2s
- retry_max_delay: 1m
-
-channels:
- reconcile_interval: 10s
- lease_duration: 30s
- drain_timeout: 5s
- retry_base_delay: 1s
- retry_max_delay: 1m
- mutation_cooldown: 1s
- mutation_timeout: 30s
-
-security:
- csrf:
- # Public development placeholder, independent from the SecretStore key.
- key: AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=
- secure: false
- trusted_origins: ["mooncode.localhost:3100"]
- rate_limit:
- per_ip: {rate: 30, burst: 60}
- per_user: {rate: 20, burst: 40}
- per_workspace: {rate: 50, burst: 100}
- entry_ttl: 10m
- max_entries: 10000
-
-server:
- address: ":8080"
- read_header_timeout: 5s
- read_timeout: 15s
- write_timeout: 30s
- idle_timeout: 60s
-
-admin:
- # Keep loopback unless an isolated management network is enforced outside
- # the process.
- address: "127.0.0.1:9090"
- read_header_timeout: 3s
- read_timeout: 5s
- write_timeout: 10s
- idle_timeout: 30s
-
-log:
- level: info
- format: console
-
-observability:
- tracing:
- enabled: false
- endpoint: "http://127.0.0.1:4318"
- protocol: http/protobuf
- sample_ratio: 1.0
- exporter_timeout: 5s
-
-shutdown:
- timeout: 15s
+repository:
+ root: /var/lib/mooncode/repositories
+ worktree_max_age: 24h
+ max_mirror_bytes: 10737418240
+ max_per_workspace: 100
+
+hatchet:
+ token: REPLACE_WITH_HATCHET_CLIENT_TOKEN
+ address: hatchet:7077
+ namespace: mooncode
+
+analysis:
+ scc_path: scc
+ timeout: 15m
+ max_output_bytes: 16777216
+ max_concurrent_per_workspace: 10
+
+metrics:
+ # Worker-only health and Prometheus endpoint; keep on the private service network.
+ worker_address: ":9090"
diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go
new file mode 100644
index 0000000..e000f54
--- /dev/null
+++ b/pkg/analyzer/analyzer.go
@@ -0,0 +1,25 @@
+package analyzer
+
+import (
+ "context"
+ "encoding/json"
+)
+
+type Request struct {
+ Directory string
+ CommitSHA string
+ Parameters json.RawMessage
+}
+
+type Result struct {
+ DimensionKey string
+ AnalyzerKey string
+ AnalyzerVersion string
+ Data json.RawMessage
+ RawArtifact json.RawMessage
+ Warnings []string
+}
+
+type Analyzer interface {
+ Analyze(context.Context, Request) (Result, error)
+}
diff --git a/pkg/analyzer/errors.go b/pkg/analyzer/errors.go
new file mode 100644
index 0000000..909b4f7
--- /dev/null
+++ b/pkg/analyzer/errors.go
@@ -0,0 +1,8 @@
+package analyzer
+
+import "errors"
+
+var (
+ ErrOutputTooLarge = errors.New("analyzer output exceeded the allowed size")
+ ErrInvalidOutput = errors.New("analyzer output is invalid")
+)
diff --git a/pkg/analyzer/scc/analyzer.go b/pkg/analyzer/scc/analyzer.go
new file mode 100644
index 0000000..2fb8cc4
--- /dev/null
+++ b/pkg/analyzer/scc/analyzer.go
@@ -0,0 +1,64 @@
+package scc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os/exec"
+ "strings"
+ "time"
+
+ "github.com/fuchencong/mooncode/pkg/analyzer"
+)
+
+func (a *Default) Analyze(ctx context.Context, request analyzer.Request) (analyzer.Result, error) {
+ command := exec.CommandContext(ctx, a.binary, "--format", "json", request.Directory)
+ command.Env = append([]string(nil), a.environment...)
+ configureProcess(command)
+ command.WaitDelay = 2 * time.Second
+
+ stdout := newLimitedBuffer(a.maxOutputBytes)
+ stderr := newLimitedBuffer(a.maxDiagnosticBytes)
+ command.Stdout = stdout
+ command.Stderr = stderr
+ err := command.Run()
+ if stdout.Exceeded() {
+ return analyzer.Result{}, analyzer.ErrOutputTooLarge
+ }
+ if ctx.Err() != nil {
+ return analyzer.Result{}, fmt.Errorf("run scc: %w", ctx.Err())
+ }
+ if err != nil {
+ diagnostic := strings.TrimSpace(stderr.String())
+ if diagnostic == "" {
+ return analyzer.Result{}, fmt.Errorf("run scc: %w", err)
+ }
+ if stderr.Exceeded() {
+ diagnostic += " [truncated]"
+ }
+
+ return analyzer.Result{}, fmt.Errorf("run scc: %w: %s", err, diagnostic)
+ }
+ if stderr.Exceeded() {
+ return analyzer.Result{}, errors.New("scc diagnostic output exceeded the allowed size")
+ }
+
+ result, err := parse(stdout.Bytes())
+ if err != nil {
+ return analyzer.Result{}, err
+ }
+ data, err := json.Marshal(result)
+ if err != nil {
+ return analyzer.Result{}, fmt.Errorf("%w: encode normalized scc result: %v", analyzer.ErrInvalidOutput, err)
+ }
+
+ return analyzer.Result{
+ DimensionKey: "code_scale",
+ AnalyzerKey: "scc",
+ AnalyzerVersion: Version,
+ Data: data,
+ RawArtifact: append(json.RawMessage(nil), result.Raw...),
+ Warnings: append([]string(nil), result.Warnings...),
+ }, nil
+}
diff --git a/pkg/analyzer/scc/analyzer_test.go b/pkg/analyzer/scc/analyzer_test.go
new file mode 100644
index 0000000..fbe37ef
--- /dev/null
+++ b/pkg/analyzer/scc/analyzer_test.go
@@ -0,0 +1,106 @@
+package scc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/fuchencong/mooncode/pkg/analyzer"
+)
+
+func TestAnalyzerAggregatesLanguages(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "scc")
+ script := `#!/bin/sh
+printf '%s' '[{"Name":"Go","Lines":10,"Code":8,"Comment":1,"Blank":1,"Complexity":2,"Count":2,"Bytes":100},{"Name":"SQL","Lines":5,"Code":4,"Comment":0,"Blank":1,"Complexity":0,"Count":1,"Bytes":50}]'
+`
+ if err := os.WriteFile(binary, []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ result, err := New(WithBinary(binary)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var data Result
+ if err := json.Unmarshal(result.Data, &data); err != nil {
+ t.Fatal(err)
+ }
+ if result.DimensionKey != "code_scale" || result.AnalyzerKey != "scc" || result.AnalyzerVersion != Version {
+ t.Fatalf("unexpected analyzer metadata: %#v", result)
+ }
+ if data.Summary.Files != 3 || data.Summary.Code != 12 || len(data.Languages) != 2 {
+ t.Fatalf("unexpected result: %#v", result)
+ }
+ if !json.Valid(result.RawArtifact) {
+ t.Fatalf("raw artifact is invalid JSON: %s", result.RawArtifact)
+ }
+}
+
+func TestAnalyzerRejectsOversizedOutput(t *testing.T) {
+ binary := executable(t, "#!/bin/sh\nprintf '123456789'\n")
+ _, err := New(WithBinary(binary), WithMaxOutputBytes(8)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()})
+ if !errors.Is(err, analyzer.ErrOutputTooLarge) {
+ t.Fatalf("oversized output error = %v, want ErrOutputTooLarge", err)
+ }
+}
+
+func TestAnalyzerCancellationKillsProcessGroup(t *testing.T) {
+ binary := executable(t, "#!/bin/sh\nsleep 30 &\nwait\n")
+ ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
+ defer cancel()
+ started := time.Now()
+ _, err := New(WithBinary(binary)).Analyze(ctx, analyzer.Request{Directory: t.TempDir()})
+ if !errors.Is(err, context.DeadlineExceeded) {
+ t.Fatalf("cancelled analyzer error = %v, want context deadline", err)
+ }
+ if elapsed := time.Since(started); elapsed > 2*time.Second {
+ t.Fatalf("cancelled process group took %s to stop", elapsed)
+ }
+}
+
+func TestAnalyzerRejectsInvalidMetricsAndTrailingJSON(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ output string
+ }{
+ {name: "negative metric", output: `[{"Name":"Go","Lines":-1}]`},
+ {name: "trailing value", output: `[] {}`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ binary := executable(t, "#!/bin/sh\nprintf '%s' '"+test.output+"'\n")
+ if _, err := New(WithBinary(binary)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()}); err == nil {
+ t.Fatal("invalid scc output was accepted")
+ }
+ })
+ }
+}
+
+func TestAnalyzerDoesNotInheritWorkerSecrets(t *testing.T) {
+ t.Setenv("MOONCODE_SECRETS_KEY", "must-not-reach-analyzer")
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://secret")
+ t.Setenv("MOONCODE_HATCHET_TOKEN", "hatchet-secret")
+ binary := executable(t, `#!/bin/sh
+if env | grep -E '^(MOONCODE_SECRETS_KEY|MOONCODE_DATABASE_URL|MOONCODE_HATCHET_TOKEN)=' >/dev/null; then
+ echo 'worker secret reached analyzer' >&2
+ exit 1
+fi
+printf '[]'
+`)
+
+ if _, err := New(WithBinary(binary)).Analyze(context.Background(), analyzer.Request{Directory: t.TempDir()}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func executable(t *testing.T, contents string) string {
+ t.Helper()
+ binary := filepath.Join(t.TempDir(), "scc")
+ if err := os.WriteFile(binary, []byte(contents), 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ return binary
+}
diff --git a/pkg/analyzer/scc/limit.go b/pkg/analyzer/scc/limit.go
new file mode 100644
index 0000000..c97de13
--- /dev/null
+++ b/pkg/analyzer/scc/limit.go
@@ -0,0 +1,43 @@
+package scc
+
+import (
+ "bytes"
+ "errors"
+)
+
+var errLimitExceeded = errors.New("output limit exceeded")
+
+type limitedBuffer struct {
+ buffer bytes.Buffer
+ limit int64
+ exceeded bool
+}
+
+func newLimitedBuffer(limit int64) *limitedBuffer {
+ return &limitedBuffer{limit: limit}
+}
+
+func (b *limitedBuffer) Write(value []byte) (int, error) {
+ remaining := b.limit - int64(b.buffer.Len())
+ if int64(len(value)) <= remaining {
+ return b.buffer.Write(value)
+ }
+ if remaining > 0 {
+ _, _ = b.buffer.Write(value[:remaining])
+ }
+ b.exceeded = true
+
+ return int(remaining), errLimitExceeded
+}
+
+func (b *limitedBuffer) Bytes() []byte {
+ return b.buffer.Bytes()
+}
+
+func (b *limitedBuffer) String() string {
+ return b.buffer.String()
+}
+
+func (b *limitedBuffer) Exceeded() bool {
+ return b.exceeded
+}
diff --git a/pkg/analyzer/scc/model.go b/pkg/analyzer/scc/model.go
new file mode 100644
index 0000000..032d7b7
--- /dev/null
+++ b/pkg/analyzer/scc/model.go
@@ -0,0 +1,35 @@
+package scc
+
+import (
+ "encoding/json"
+)
+
+const Version = "scc-3.4.0-json-v1"
+
+type Summary struct {
+ Files int64 `json:"files"`
+ Lines int64 `json:"lines"`
+ Code int64 `json:"code"`
+ Comments int64 `json:"comments"`
+ Blanks int64 `json:"blanks"`
+ Bytes int64 `json:"bytes"`
+ Complexity int64 `json:"complexity"`
+}
+
+type Language struct {
+ Name string `json:"name"`
+ Files int64 `json:"files"`
+ Lines int64 `json:"lines"`
+ Code int64 `json:"code"`
+ Comments int64 `json:"comments"`
+ Blanks int64 `json:"blanks"`
+ Bytes int64 `json:"bytes"`
+ Complexity int64 `json:"complexity"`
+}
+
+type Result struct {
+ Summary Summary `json:"summary"`
+ Languages []Language `json:"languages"`
+ Warnings []string `json:"warnings"`
+ Raw json.RawMessage `json:"-"`
+}
diff --git a/pkg/analyzer/scc/options.go b/pkg/analyzer/scc/options.go
new file mode 100644
index 0000000..6ce77ad
--- /dev/null
+++ b/pkg/analyzer/scc/options.go
@@ -0,0 +1,48 @@
+package scc
+
+const (
+ defaultMaxOutputBytes = 16 << 20
+ defaultMaxDiagnosticBytes = 64 << 10
+)
+
+type Option func(*Default)
+
+type Default struct {
+ binary string
+ environment []string
+ maxOutputBytes int64
+ maxDiagnosticBytes int64
+}
+
+func WithBinary(path string) Option {
+ return func(analyzer *Default) {
+ analyzer.binary = path
+ }
+}
+
+func WithMaxOutputBytes(maxBytes int64) Option {
+ return func(analyzer *Default) {
+ if maxBytes > 0 {
+ analyzer.maxOutputBytes = maxBytes
+ }
+ }
+}
+
+func WithEnvironment(environment ...string) Option {
+ return func(analyzer *Default) {
+ analyzer.environment = append([]string(nil), environment...)
+ }
+}
+
+func New(options ...Option) *Default {
+ analyzer := &Default{
+ binary: "scc", environment: []string{"LANG=C", "LC_ALL=C", "PATH=/usr/local/bin:/usr/bin:/bin", "TZ=UTC"},
+ maxOutputBytes: defaultMaxOutputBytes,
+ maxDiagnosticBytes: defaultMaxDiagnosticBytes,
+ }
+ for _, option := range options {
+ option(analyzer)
+ }
+
+ return analyzer
+}
diff --git a/pkg/analyzer/scc/parse.go b/pkg/analyzer/scc/parse.go
new file mode 100644
index 0000000..e4c9095
--- /dev/null
+++ b/pkg/analyzer/scc/parse.go
@@ -0,0 +1,69 @@
+package scc
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/fuchencong/mooncode/pkg/analyzer"
+)
+
+type rawLanguage struct {
+ Name string `json:"Name"`
+ Lines int64 `json:"Lines"`
+ Code int64 `json:"Code"`
+ Comment int64 `json:"Comment"`
+ Blank int64 `json:"Blank"`
+ Complexity int64 `json:"Complexity"`
+ Count int64 `json:"Count"`
+ Bytes int64 `json:"Bytes"`
+}
+
+func parse(output []byte) (Result, error) {
+ var rows []rawLanguage
+ decoder := json.NewDecoder(bytes.NewReader(output))
+ if err := decoder.Decode(&rows); err != nil {
+ return Result{}, fmt.Errorf("%w: decode JSON: %v", analyzer.ErrInvalidOutput, err)
+ }
+ if err := rejectTrailingJSON(decoder); err != nil {
+ return Result{}, err
+ }
+
+ result := Result{Languages: make([]Language, 0, len(rows)), Warnings: []string{}, Raw: append(json.RawMessage(nil), output...)}
+ for _, row := range rows {
+ if strings.TrimSpace(row.Name) == "" || hasNegativeMetric(row) {
+ return Result{}, fmt.Errorf("%w: language metrics are invalid", analyzer.ErrInvalidOutput)
+ }
+ language := Language{Name: row.Name, Files: row.Count, Lines: row.Lines, Code: row.Code, Comments: row.Comment, Blanks: row.Blank, Bytes: row.Bytes, Complexity: row.Complexity}
+ result.Languages = append(result.Languages, language)
+ result.Summary.Files += language.Files
+ result.Summary.Lines += language.Lines
+ result.Summary.Code += language.Code
+ result.Summary.Comments += language.Comments
+ result.Summary.Blanks += language.Blanks
+ result.Summary.Bytes += language.Bytes
+ result.Summary.Complexity += language.Complexity
+ }
+
+ return result, nil
+}
+
+func rejectTrailingJSON(decoder *json.Decoder) error {
+ var trailing any
+ err := decoder.Decode(&trailing)
+ if errors.Is(err, io.EOF) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("%w: decode trailing JSON: %v", analyzer.ErrInvalidOutput, err)
+ }
+
+ return fmt.Errorf("%w: multiple JSON values", analyzer.ErrInvalidOutput)
+}
+
+func hasNegativeMetric(row rawLanguage) bool {
+ return row.Lines < 0 || row.Code < 0 || row.Comment < 0 || row.Blank < 0 || row.Complexity < 0 || row.Count < 0 || row.Bytes < 0
+}
diff --git a/pkg/analyzer/scc/process_other.go b/pkg/analyzer/scc/process_other.go
new file mode 100644
index 0000000..36dff14
--- /dev/null
+++ b/pkg/analyzer/scc/process_other.go
@@ -0,0 +1,7 @@
+//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris
+
+package scc
+
+import "os/exec"
+
+func configureProcess(*exec.Cmd) {}
diff --git a/pkg/analyzer/scc/process_unix.go b/pkg/analyzer/scc/process_unix.go
new file mode 100644
index 0000000..185462c
--- /dev/null
+++ b/pkg/analyzer/scc/process_unix.go
@@ -0,0 +1,28 @@
+//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
+
+package scc
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "syscall"
+)
+
+func configureProcess(command *exec.Cmd) {
+ command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ command.Cancel = func() error {
+ if command.Process == nil {
+ return os.ErrProcessDone
+ }
+ if err := syscall.Kill(-command.Process.Pid, syscall.SIGKILL); err != nil {
+ if errors.Is(err, syscall.ESRCH) {
+ return os.ErrProcessDone
+ }
+
+ return err
+ }
+
+ return nil
+ }
+}
diff --git a/pkg/channel/base.go b/pkg/channel/base.go
deleted file mode 100644
index 800c662..0000000
--- a/pkg/channel/base.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package channel
-
-import (
- "context"
- "errors"
- "fmt"
- "strings"
- "time"
- "unicode/utf8"
-)
-
-type BaseOption func(*BaseChannel)
-
-func WithGroupPolicy(policy GroupPolicy) BaseOption {
- return func(base *BaseChannel) { base.groupPolicy = policy }
-}
-func WithMaxMessageBytes(limit int) BaseOption {
- return func(base *BaseChannel) {
- if limit > 0 {
- base.maxMessageBytes = limit
- }
- }
-}
-func WithClock(clock func() time.Time) BaseOption {
- return func(base *BaseChannel) {
- if clock != nil {
- base.clock = clock
- }
- }
-}
-func WithAttributeSchema(schema AttributeSchema) BaseOption {
- return func(base *BaseChannel) { base.attributes = schema }
-}
-func WithInboundMiddlewares(middleware ...InboundMiddleware) BaseOption {
- return func(base *BaseChannel) { base.middleware = append(base.middleware, middleware...) }
-}
-
-type BaseChannel struct {
- channelType string
- accountID string
- sink InboundSink
- allowList map[string]struct{}
- groupPolicy GroupPolicy
- maxMessageBytes int
- attributes AttributeSchema
- clock func() time.Time
- middleware []InboundMiddleware
-}
-
-func NewBaseChannel(channelType string, config InstanceConfig, sink InboundSink, opts ...BaseOption) (*BaseChannel, error) {
- if strings.TrimSpace(channelType) == "" || strings.TrimSpace(config.AccountID) == "" || sink == nil {
- return nil, errors.New("channel type, account ID, and inbound sink are required")
- }
- base := &BaseChannel{channelType: strings.ToLower(channelType), accountID: config.AccountID, sink: sink, allowList: make(map[string]struct{}), groupPolicy: config.GroupPolicy, maxMessageBytes: 64 << 10, clock: time.Now}
- for _, sender := range config.SenderAllowList {
- sender = strings.TrimSpace(sender)
- if sender != "" {
- base.allowList[sender] = struct{}{}
- }
- }
- for _, option := range opts {
- if option != nil {
- option(base)
- }
- }
- return base, nil
-}
-
-func (b *BaseChannel) Accept(ctx context.Context, message InboundMessage) (AcceptResult, error) {
- message.AccountID, message.ChannelType = b.accountID, b.channelType
- var result AcceptResult
- handler := WrapInbound(func(ctx context.Context, message InboundMessage) error {
- var err error
- result, err = b.accept(ctx, message)
- return err
- }, b.middleware...)
- return result, handler(ctx, message)
-}
-
-func (b *BaseChannel) accept(ctx context.Context, message InboundMessage) (AcceptResult, error) {
- message.ExternalEventID, message.ExternalMessageID = strings.TrimSpace(message.ExternalEventID), strings.TrimSpace(message.ExternalMessageID)
- message.Conversation.ID, message.Conversation.Type = strings.TrimSpace(message.Conversation.ID), strings.ToLower(strings.TrimSpace(message.Conversation.Type))
- message.Sender.PlatformID = strings.TrimSpace(message.Sender.PlatformID)
- if message.ExternalEventID == "" || message.ExternalMessageID == "" || message.Conversation.ID == "" || message.Sender.PlatformID == "" {
- return "", ErrInvalidMessage
- }
- if _, all := b.allowList["*"]; !all {
- if _, allowed := b.allowList[message.Sender.PlatformID]; !allowed {
- return "", fmt.Errorf("%w: sender is not allowed", ErrRejected)
- }
- }
- if message.Conversation.Type != "direct" {
- prefixMatched := b.groupPolicy.Prefix != "" && strings.HasPrefix(strings.TrimSpace(message.Content.Text), b.groupPolicy.Prefix)
- if b.groupPolicy.RequireMention && !message.Mentioned && !prefixMatched {
- return "", fmt.Errorf("%w: group trigger did not match", ErrRejected)
- }
- if prefixMatched {
- message.Content.Text = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(message.Content.Text), b.groupPolicy.Prefix))
- }
- }
- message.Content.Type, message.Content.Text = strings.ToLower(strings.TrimSpace(message.Content.Type)), strings.TrimSpace(message.Content.Text)
- if !utf8.ValidString(message.Content.Text) || len(message.Content.Text) > b.maxMessageBytes {
- return "", fmt.Errorf("%w: content size is invalid", ErrRejected)
- }
- message.Sender.CanonicalID = b.channelType + ":" + message.Sender.PlatformID
- if message.OccurredAt.IsZero() {
- message.OccurredAt = b.clock().UTC()
- }
- if message.ReceivedAt.IsZero() {
- message.ReceivedAt = b.clock().UTC()
- }
- message.Attributes = filterAttributes(message.Attributes, b.attributes)
- return b.sink.Accept(ctx, message)
-}
-
-func filterAttributes(values map[string]string, schema AttributeSchema) map[string]string {
- result := make(map[string]string)
- allowed := make(map[string]AttributeField, len(schema.Fields))
- for _, field := range schema.Fields {
- allowed[field.Key] = field
- }
- for key, value := range values {
- field, ok := allowed[key]
- if !ok || len(value) > field.MaxBytes {
- continue
- }
- if len(field.Enum) > 0 {
- valid := false
- for _, candidate := range field.Enum {
- if value == candidate {
- valid = true
- break
- }
- }
- if !valid {
- continue
- }
- }
- result[key] = value
- }
- return result
-}
diff --git a/pkg/channel/channel.go b/pkg/channel/channel.go
index 1c8dc78..b7b6ad4 100644
--- a/pkg/channel/channel.go
+++ b/pkg/channel/channel.go
@@ -1,144 +1,41 @@
-// Package channel defines the reusable inbound messaging extension boundary.
-// It deliberately has no MoonCode workspace, database, or HTTP dependencies.
package channel
import (
"context"
- "errors"
"time"
)
-var (
- ErrRejected = errors.New("channel: message rejected")
- ErrInvalidMessage = errors.New("channel: invalid message")
-)
-
-type Channel interface {
- Type() string
- Run(ctx context.Context) error
+type Message struct {
+ Text string
+ TargetID string
+ TargetType string
}
-type InboundSink interface {
- Accept(ctx context.Context, message InboundMessage) (AcceptResult, error)
-}
-type AcceptResult string
-
-const (
- Accepted AcceptResult = "accepted"
- AlreadyAccepted AcceptResult = "already_accepted"
-)
-type InboundHandler func(context.Context, InboundMessage) error
-type InboundMiddleware func(InboundHandler) InboundHandler
-
-// WrapInbound applies middleware in declaration order: the first middleware
-// is the outermost boundary.
-func WrapInbound(handler InboundHandler, middleware ...InboundMiddleware) InboundHandler {
- for index := len(middleware) - 1; index >= 0; index-- {
- if middleware[index] != nil {
- handler = middleware[index](handler)
- }
- }
- return handler
+type Sender interface {
+ Send(context.Context, Message) error
}
-type ConversationRef struct {
- ID string `json:"id"`
- Type string `json:"type"`
- TopicID string `json:"topicId,omitempty"`
- SpaceID string `json:"spaceId,omitempty"`
-}
-type SenderInfo struct {
- PlatformID string `json:"platformId"`
- CanonicalID string `json:"canonicalId"`
- Username string `json:"username,omitempty"`
- DisplayName string `json:"displayName,omitempty"`
-}
-type AttachmentRef struct {
- Type string `json:"type"`
- ExternalID string `json:"externalId"`
- Name string `json:"name,omitempty"`
- Size int64 `json:"size,omitempty"`
-}
-type MessageContent struct {
- Type string `json:"type"`
- Text string `json:"text"`
- Attachments []AttachmentRef `json:"attachments,omitempty"`
-}
type InboundMessage struct {
- AccountID string
- ChannelType string
- ExternalEventID string
- ExternalMessageID string
- Conversation ConversationRef
- Sender SenderInfo
- Content MessageContent
+ ExternalID string
+ ConversationID string
+ ConversationType string
+ ConversationTitle string
+ SenderCanonicalID string
+ SenderDisplayName string
+ Text string
Mentioned bool
- ReplyToMessageID string
OccurredAt time.Time
- ReceivedAt time.Time
- Attributes map[string]string
}
-type InstanceConfig struct {
- AccountID string
- Values map[string]any
- Secrets map[string]string
- SenderAllowList []string
- GroupPolicy GroupPolicy
-}
-
-type GroupPolicy struct {
- RequireMention bool `json:"requireMention"`
- Prefix string `json:"prefix"`
-}
-type ChannelOption func(*ChannelOptions)
-type ChannelOptions struct{ InboundMiddleware []InboundMiddleware }
-
-func WithInboundMiddleware(middleware ...InboundMiddleware) ChannelOption {
- return func(options *ChannelOptions) {
- options.InboundMiddleware = append(options.InboundMiddleware, middleware...)
- }
-}
+type Handler func(context.Context, InboundMessage) error
-func ResolveChannelOptions(opts ...ChannelOption) ChannelOptions {
- var options ChannelOptions
- for _, option := range opts {
- if option != nil {
- option(&options)
- }
- }
- return options
+type Connection interface {
+ Sender
+ Start(context.Context, Handler) error
+ Close() error
}
type Factory interface {
Type() string
- Descriptor() Descriptor
- Validate(config InstanceConfig) error
- New(config InstanceConfig, sink InboundSink, opts ...ChannelOption) (Channel, error)
-}
-
-type Descriptor struct {
- Type string `json:"type"`
- DisplayName string `json:"displayName"`
- Icon string `json:"icon"`
- Capabilities []string `json:"capabilities"`
- Fields []ConfigField `json:"fields"`
- AttributeSchema AttributeSchema `json:"attributeSchema"`
- DocumentationURL string `json:"documentationUrl"`
-}
-type ConfigField struct {
- Name string `json:"name"`
- Type string `json:"type"`
- Required bool `json:"required"`
- Secret bool `json:"secret"`
- Help string `json:"help"`
- Enum []string `json:"enum,omitempty"`
-}
-type AttributeSchema struct {
- Fields []AttributeField `json:"fields"`
-}
-type AttributeField struct {
- Key string `json:"key"`
- MaxBytes int `json:"maxBytes"`
- Enum []string `json:"enum,omitempty"`
+ New(map[string]any, string) (Connection, error)
}
diff --git a/pkg/channel/channel_test.go b/pkg/channel/channel_test.go
deleted file mode 100644
index c352e40..0000000
--- a/pkg/channel/channel_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package channel
-
-import (
- "context"
- "encoding/json"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-type captureSink struct{ message InboundMessage }
-
-func (s *captureSink) Accept(_ context.Context, message InboundMessage) (AcceptResult, error) {
- s.message = message
- return Accepted, nil
-}
-func TestBaseChannelPolicyAndNormalization(t *testing.T) {
- sink := &captureSink{}
- base, err := NewBaseChannel("feishu", InstanceConfig{AccountID: "account", SenderAllowList: []string{"sender"}, GroupPolicy: GroupPolicy{RequireMention: true, Prefix: "/moon"}}, sink, WithAttributeSchema(AttributeSchema{Fields: []AttributeField{{Key: "tenant", MaxBytes: 8}}}))
- require.NoError(t, err)
- result, err := base.Accept(context.Background(), InboundMessage{ExternalEventID: "event", ExternalMessageID: "message", Conversation: ConversationRef{ID: "chat", Type: "group"}, Sender: SenderInfo{PlatformID: "sender"}, Content: MessageContent{Type: "text", Text: "/moon hello"}, Attributes: map[string]string{"tenant": "one", "raw": "drop"}})
- require.NoError(t, err)
- require.Equal(t, Accepted, result)
- require.Equal(t, "hello", sink.message.Content.Text)
- require.Equal(t, "feishu:sender", sink.message.Sender.CanonicalID)
- require.Equal(t, map[string]string{"tenant": "one"}, sink.message.Attributes)
-}
-func TestBaseChannelEmptyAllowListRejects(t *testing.T) {
- base, err := NewBaseChannel("dingtalk", InstanceConfig{AccountID: "a"}, &captureSink{})
- require.NoError(t, err)
- _, err = base.Accept(context.Background(), InboundMessage{ExternalEventID: "e", ExternalMessageID: "m", Conversation: ConversationRef{ID: "c", Type: "direct"}, Sender: SenderInfo{PlatformID: "s"}, Content: MessageContent{Type: "text", Text: "hello"}})
- require.ErrorIs(t, err, ErrRejected)
-}
-
-func TestInboundMiddlewareUsesDeclarationOrder(t *testing.T) {
- var calls []string
- middleware := func(name string) InboundMiddleware {
- return func(next InboundHandler) InboundHandler {
- return func(ctx context.Context, message InboundMessage) error {
- calls = append(calls, name+":before")
- err := next(ctx, message)
- calls = append(calls, name+":after")
- return err
- }
- }
- }
- handler := WrapInbound(func(context.Context, InboundMessage) error {
- calls = append(calls, "handler")
- return nil
- }, middleware("outer"), middleware("inner"))
- require.NoError(t, handler(t.Context(), InboundMessage{}))
- require.Equal(t, []string{"outer:before", "inner:before", "handler", "inner:after", "outer:after"}, calls)
-}
-
-func TestGroupPolicyUsesPublicJSONContract(t *testing.T) {
- encoded, err := json.Marshal(GroupPolicy{RequireMention: true, Prefix: "/moon"})
- require.NoError(t, err)
- require.JSONEq(t, `{"requireMention":true,"prefix":"/moon"}`, string(encoded))
-}
diff --git a/pkg/channel/dingtalk/connection.go b/pkg/channel/dingtalk/connection.go
new file mode 100644
index 0000000..e2d98b6
--- /dev/null
+++ b/pkg/channel/dingtalk/connection.go
@@ -0,0 +1,78 @@
+package dingtalk
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+)
+
+const connectionTimeout = 30 * time.Second
+
+type connection struct {
+ *sender
+
+ mu sync.Mutex
+ streamClient *client.StreamClient
+ cancel context.CancelFunc
+}
+
+func (c *connection) Start(ctx context.Context, handler channel.Handler) error {
+ if handler == nil {
+ return errors.New("DingTalk inbound handler is required")
+ }
+
+ c.mu.Lock()
+ if c.cancel != nil {
+ c.mu.Unlock()
+ return errors.New("DingTalk connection is already started")
+ }
+ runContext, cancel := context.WithCancel(ctx)
+ streamClient := client.NewStreamClient(
+ client.WithAppCredential(client.NewAppCredentialConfig(c.clientID, c.clientSecret)),
+ client.WithAutoReconnect(true),
+ )
+ streamClient.RegisterChatBotCallbackRouter(func(eventContext context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
+ message, ok := parseInbound(data, time.Now)
+ if !ok {
+ return nil, nil
+ }
+
+ return nil, handler(eventContext, message)
+ })
+ c.cancel = cancel
+ c.streamClient = streamClient
+ c.mu.Unlock()
+
+ startContext, stopStarting := context.WithTimeout(runContext, connectionTimeout)
+ err := streamClient.Start(startContext)
+ stopStarting()
+ if err != nil {
+ _ = c.Close()
+ return fmt.Errorf("start DingTalk stream: %w", err)
+ }
+
+ return nil
+}
+
+func (c *connection) Close() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.cancel != nil {
+ c.cancel()
+ c.cancel = nil
+ }
+ if c.streamClient != nil {
+ c.streamClient.Close()
+ c.streamClient = nil
+ }
+
+ return nil
+}
diff --git a/pkg/channel/dingtalk/dingtalk.go b/pkg/channel/dingtalk/dingtalk.go
index 9cabe6e..cf523df 100644
--- a/pkg/channel/dingtalk/dingtalk.go
+++ b/pkg/channel/dingtalk/dingtalk.go
@@ -1,98 +1,103 @@
-// Package dingtalk implements a channel.Factory with the official Stream SDK.
package dingtalk
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
+ "net/http"
"strings"
"time"
- "github.com/mooncode-ai/mooncode/pkg/channel"
- "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
- "github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
+ "github.com/fuchencong/mooncode/pkg/channel"
)
-type Factory struct{}
-type FactoryOption func(*Factory)
+type Option func(*Factory)
+type Factory struct{ client *http.Client }
-func NewFactory(opts ...FactoryOption) *Factory {
- factory := &Factory{}
- for _, option := range opts {
- if option != nil {
- option(factory)
- }
- }
- return factory
-}
-func (*Factory) Type() string { return "dingtalk" }
-func (*Factory) Descriptor() channel.Descriptor {
- return channel.Descriptor{Type: "dingtalk", DisplayName: "DingTalk", Icon: "message-square", Capabilities: []string{"receive"}, DocumentationURL: "https://open.dingtalk.com/document/isvapp/streaming-overview", Fields: []channel.ConfigField{{Name: "client_id", Type: "string", Required: true, Help: "Application Client ID"}, {Name: "client_secret", Type: "password", Required: true, Secret: true}}, AttributeSchema: channel.AttributeSchema{Fields: []channel.AttributeField{{Key: "corp_id", MaxBytes: 128}, {Key: "conversation_title", MaxBytes: 512}}}}
+func WithHTTPClient(client *http.Client) Option {
+ return func(factory *Factory) { factory.client = client }
}
-func (*Factory) Validate(config channel.InstanceConfig) error {
- if stringValue(config.Values, "client_id") == "" || config.Secrets["client_secret"] == "" {
- return errors.New("dingtalk client_id and client_secret are required")
+func New(options ...Option) *Factory {
+ value := &Factory{client: &http.Client{Timeout: 15 * time.Second}}
+ for _, option := range options {
+ option(value)
}
- return nil
+ return value
}
-func (factory *Factory) New(config channel.InstanceConfig, sink channel.InboundSink, opts ...channel.ChannelOption) (channel.Channel, error) {
- if err := factory.Validate(config); err != nil {
- return nil, err
- }
- options := channel.ResolveChannelOptions(opts...)
- base, err := channel.NewBaseChannel(factory.Type(), config, sink, channel.WithAttributeSchema(factory.Descriptor().AttributeSchema), channel.WithInboundMiddlewares(options.InboundMiddleware...))
- if err != nil {
- return nil, err
+func (*Factory) Type() string { return "dingtalk" }
+func (f *Factory) New(config map[string]any, clientSecret string) (channel.Connection, error) {
+ clientID, _ := config["client_id"].(string)
+ robotCode, _ := config["robot_code"].(string)
+ conversationID, _ := config["open_conversation_id"].(string)
+ if strings.TrimSpace(clientID) == "" || strings.TrimSpace(clientSecret) == "" || strings.TrimSpace(robotCode) == "" || strings.TrimSpace(conversationID) == "" {
+ return nil, errors.New("DingTalk client ID, client secret, robot code, and conversation ID are required")
}
- return &runtime{config: config, base: base}, nil
+
+ return &connection{sender: &sender{client: f.client, clientID: clientID, clientSecret: clientSecret, robotCode: robotCode, conversationID: conversationID}}, nil
}
-type runtime struct {
- config channel.InstanceConfig
- base *channel.BaseChannel
+type sender struct {
+ client *http.Client
+ clientID string
+ clientSecret string
+ robotCode string
+ conversationID string
}
-func (*runtime) Type() string { return "dingtalk" }
-func (r *runtime) Run(ctx context.Context) error {
- stream := client.NewStreamClient(client.WithAppCredential(client.NewAppCredentialConfig(stringValue(r.config.Values, "client_id"), r.config.Secrets["client_secret"])))
- stream.AutoReconnect = true
- stream.RegisterChatBotCallbackRouter(r.receive)
- if err := stream.Start(ctx); err != nil {
+func (s *sender) Send(ctx context.Context, message channel.Message) error {
+ tokenBody, _ := json.Marshal(map[string]string{"appKey": s.clientID, "appSecret": s.clientSecret})
+ tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.dingtalk.com/v1.0/oauth2/accessToken", bytes.NewReader(tokenBody))
+ if err != nil {
return err
}
- <-ctx.Done()
- stream.AutoReconnect = false
- stream.Close()
- return nil
-}
-func (r *runtime) receive(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
- message, err := normalize(data)
+ tokenRequest.Header.Set("Content-Type", "application/json")
+ tokenResponse, err := s.client.Do(tokenRequest)
if err != nil {
- return nil, err
+ return err
}
- _, err = r.base.Accept(ctx, message)
- if errors.Is(err, channel.ErrRejected) {
- return []byte(""), nil
+ defer func() { _ = tokenResponse.Body.Close() }()
+ if tokenResponse.StatusCode/100 != 2 {
+ return channel.HTTPStatusError("DingTalk", "token", tokenResponse.StatusCode)
}
- return []byte(""), err
-}
-func normalize(data *chatbot.BotCallbackDataModel) (channel.InboundMessage, error) {
- if data == nil {
- return channel.InboundMessage{}, channel.ErrInvalidMessage
+ var token struct {
+ AccessToken string `json:"accessToken"`
+ }
+ if err := json.NewDecoder(tokenResponse.Body).Decode(&token); err != nil {
+ return err
}
- senderID := strings.TrimSpace(data.SenderStaffId)
- if senderID == "" {
- senderID = strings.TrimSpace(data.SenderId)
+ if token.AccessToken == "" {
+ return channel.Permanent(errors.New("DingTalk token response did not contain an access token"))
}
- conversationType := "group"
- if data.ConversationType == "1" {
- conversationType = "direct"
+
+ parameter, _ := json.Marshal(map[string]string{"content": message.Text})
+ endpoint := "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
+ body, _ := json.Marshal(map[string]any{"robotCode": s.robotCode, "openConversationId": target(message.TargetID, s.conversationID), "msgKey": "sampleText", "msgParam": string(parameter)})
+ if message.TargetType == "direct" && strings.TrimSpace(message.TargetID) != "" {
+ endpoint = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend"
+ body, _ = json.Marshal(map[string]any{"robotCode": s.robotCode, "userIds": []string{strings.TrimSpace(message.TargetID)}, "msgKey": "sampleText", "msgParam": string(parameter)})
}
- return channel.InboundMessage{ExternalEventID: "message:" + data.MsgId, ExternalMessageID: data.MsgId, Conversation: channel.ConversationRef{ID: data.ConversationId, Type: conversationType}, Sender: channel.SenderInfo{PlatformID: senderID, DisplayName: data.SenderNick}, Content: channel.MessageContent{Type: data.Msgtype, Text: data.Text.Content}, Mentioned: data.IsInAtList, OccurredAt: time.UnixMilli(data.CreateAt).UTC(), Attributes: map[string]string{"corp_id": data.SenderCorpId, "conversation_title": data.ConversationTitle}}, nil
-}
-func stringValue(values map[string]any, key string) string {
- value, _ := values[key].(string)
- return strings.TrimSpace(value)
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return err
+ }
+ request.Header.Set("x-acs-dingtalk-access-token", token.AccessToken)
+ request.Header.Set("Content-Type", "application/json")
+ response, err := s.client.Do(request)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = response.Body.Close() }()
+ if response.StatusCode/100 != 2 {
+ return channel.HTTPStatusError("DingTalk", "message", response.StatusCode)
+ }
+ return nil
}
-var _ channel.Factory = (*Factory)(nil)
-var _ channel.Channel = (*runtime)(nil)
+func target(value, fallback string) string {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+
+ return fallback
+}
diff --git a/pkg/channel/dingtalk/dingtalk_test.go b/pkg/channel/dingtalk/dingtalk_test.go
index d57ce3f..1e3b521 100644
--- a/pkg/channel/dingtalk/dingtalk_test.go
+++ b/pkg/channel/dingtalk/dingtalk_test.go
@@ -1,25 +1,69 @@
package dingtalk
import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
"testing"
- "time"
- "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
- "github.com/stretchr/testify/require"
+ "github.com/fuchencong/mooncode/pkg/channel"
)
-func TestNormalizeMessageFixture(t *testing.T) {
- data := &chatbot.BotCallbackDataModel{
- MsgId: "msg-1", ConversationId: "conversation-1", ConversationType: "1",
- SenderStaffId: "staff-1", SenderNick: "Moon", SenderCorpId: "corp-1",
- Msgtype: "text", Text: chatbot.BotCallbackDataTextModel{Content: "hello moon"},
- CreateAt: time.Unix(1700000000, 0).UnixMilli(),
+type roundTrip func(*http.Request) (*http.Response, error)
+
+func (fn roundTrip) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
+
+func TestSenderUsesAppCredentialAndConversation(t *testing.T) {
+ requests := 0
+ client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) {
+ requests++
+ body, _ := io.ReadAll(request.Body)
+ if requests == 1 {
+ if !strings.Contains(string(body), `"appKey":"client-id"`) || !strings.Contains(string(body), `"appSecret":"secret"`) {
+ t.Fatalf("unexpected token body: %s", body)
+ }
+ return response(`{"accessToken":"access-token"}`), nil
+ }
+ if request.Header.Get("x-acs-dingtalk-access-token") != "access-token" || !strings.Contains(string(body), `"robotCode":"robot-code"`) || !strings.Contains(string(body), `"openConversationId":"conversation-id"`) {
+ t.Fatalf("unexpected message request: %s %s", request.Header, body)
+ }
+ return response(`{}`), nil
+ })}
+ sender, err := New(WithHTTPClient(client)).New(map[string]any{"client_id": "client-id", "robot_code": "robot-code", "open_conversation_id": "conversation-id"}, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sender.Send(context.Background(), channel.Message{Text: "done"}); err != nil {
+ t.Fatal(err)
+ }
+ if requests != 2 {
+ t.Fatalf("expected two requests, got %d", requests)
+ }
+}
+
+func TestSenderUsesDirectEndpointForCommandReply(t *testing.T) {
+ requests := 0
+ client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) {
+ requests++
+ if requests == 1 {
+ return response(`{"accessToken":"access-token"}`), nil
+ }
+ body, _ := io.ReadAll(request.Body)
+ if request.URL.Path != "/v1.0/robot/oToMessages/batchSend" || !strings.Contains(string(body), `"userIds":["staff-42"]`) {
+ t.Fatalf("direct reply used unexpected request: %s %s", request.URL, body)
+ }
+ return response(`{}`), nil
+ })}
+ sender, err := New(WithHTTPClient(client)).New(map[string]any{"client_id": "client-id", "robot_code": "robot-code", "open_conversation_id": "configured-conversation"}, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sender.Send(context.Background(), channel.Message{Text: "reply", TargetID: "staff-42", TargetType: "direct"}); err != nil {
+ t.Fatal(err)
}
- message, err := normalize(data)
- require.NoError(t, err)
- require.Equal(t, "message:msg-1", message.ExternalEventID)
- require.Equal(t, "staff-1", message.Sender.PlatformID)
- require.Equal(t, "direct", message.Conversation.Type)
- require.Equal(t, "hello moon", message.Content.Text)
- require.Equal(t, "corp-1", message.Attributes["corp_id"])
+}
+
+func response(body string) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
}
diff --git a/pkg/channel/dingtalk/inbound.go b/pkg/channel/dingtalk/inbound.go
new file mode 100644
index 0000000..565e2df
--- /dev/null
+++ b/pkg/channel/dingtalk/inbound.go
@@ -0,0 +1,67 @@
+package dingtalk
+
+import (
+ "strings"
+ "time"
+
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+)
+
+func parseInbound(data *chatbot.BotCallbackDataModel, now func() time.Time) (channel.InboundMessage, bool) {
+ if data == nil || data.Msgtype != "text" {
+ return channel.InboundMessage{}, false
+ }
+ text := strings.TrimSpace(data.Text.Content)
+ if text == "" {
+ if content, ok := data.Content.(map[string]any); ok {
+ text, _ = content["content"].(string)
+ text = strings.TrimSpace(text)
+ }
+ }
+ senderID := strings.TrimSpace(data.SenderStaffId)
+ if senderID == "" {
+ senderID = strings.TrimSpace(data.SenderId)
+ }
+ conversationID := strings.TrimSpace(data.ConversationId)
+ if conversationID == "" && data.ConversationType == "1" {
+ conversationID = senderID
+ }
+ if strings.TrimSpace(data.MsgId) == "" || text == "" || senderID == "" || conversationID == "" {
+ return channel.InboundMessage{}, false
+ }
+ if data.IsInAtList {
+ text = stripLeadingMentions(text)
+ }
+
+ conversationType := "group"
+ if data.ConversationType == "1" {
+ conversationType = "direct"
+ }
+ occurredAt := now().UTC()
+ if data.CreateAt > 0 {
+ occurredAt = time.UnixMilli(data.CreateAt).UTC()
+ }
+
+ return channel.InboundMessage{
+ ExternalID: strings.TrimSpace(data.MsgId),
+ ConversationID: conversationID,
+ ConversationType: conversationType,
+ ConversationTitle: strings.TrimSpace(data.ConversationTitle),
+ SenderCanonicalID: "dingtalk:" + senderID,
+ SenderDisplayName: strings.TrimSpace(data.SenderNick),
+ Text: strings.TrimSpace(text),
+ Mentioned: data.IsInAtList,
+ OccurredAt: occurredAt,
+ }, true
+}
+
+func stripLeadingMentions(value string) string {
+ fields := strings.Fields(value)
+ for len(fields) > 0 && strings.HasPrefix(fields[0], "@") {
+ fields = fields[1:]
+ }
+
+ return strings.Join(fields, " ")
+}
diff --git a/pkg/channel/dingtalk/inbound_test.go b/pkg/channel/dingtalk/inbound_test.go
new file mode 100644
index 0000000..c9ee386
--- /dev/null
+++ b/pkg/channel/dingtalk/inbound_test.go
@@ -0,0 +1,44 @@
+package dingtalk
+
+import (
+ "testing"
+ "time"
+
+ "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
+)
+
+func TestParseInboundTextMessage(t *testing.T) {
+ data := &chatbot.BotCallbackDataModel{
+ ConversationId: "cid", ConversationType: "2", ConversationTitle: "Engineering",
+ MsgId: "msg", Msgtype: "text", SenderStaffId: "staff", SenderNick: "Ada",
+ CreateAt: 1710000000123, IsInAtList: true,
+ Text: chatbot.BotCallbackDataTextModel{Content: "@MoonCode analyze this"},
+ }
+
+ message, ok := parseInbound(data, func() time.Time { return time.Time{} })
+ if !ok {
+ t.Fatal("expected message to be parsed")
+ }
+ if message.ExternalID != "msg" || message.ConversationID != "cid" || message.ConversationType != "group" {
+ t.Fatalf("unexpected message identity: %+v", message)
+ }
+ if message.SenderCanonicalID != "dingtalk:staff" || message.SenderDisplayName != "Ada" || !message.Mentioned || message.Text != "analyze this" {
+ t.Fatalf("unexpected normalized message: %+v", message)
+ }
+ if want := time.UnixMilli(1710000000123).UTC(); !message.OccurredAt.Equal(want) {
+ t.Fatalf("occurred at = %s, want %s", message.OccurredAt, want)
+ }
+}
+
+func TestParseInboundDirectConversationFallback(t *testing.T) {
+ now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC)
+ data := &chatbot.BotCallbackDataModel{
+ ConversationType: "1", MsgId: "msg", Msgtype: "text", SenderId: "sender",
+ Text: chatbot.BotCallbackDataTextModel{Content: "hello"},
+ }
+
+ message, ok := parseInbound(data, func() time.Time { return now })
+ if !ok || message.ConversationID != "sender" || message.ConversationType != "direct" || !message.OccurredAt.Equal(now) {
+ t.Fatalf("unexpected direct message: %+v, parsed=%v", message, ok)
+ }
+}
diff --git a/pkg/channel/errors.go b/pkg/channel/errors.go
new file mode 100644
index 0000000..2f296a5
--- /dev/null
+++ b/pkg/channel/errors.go
@@ -0,0 +1,42 @@
+package channel
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+)
+
+type permanentError struct {
+ cause error
+}
+
+func Permanent(cause error) error {
+ if cause == nil || IsPermanent(cause) {
+ return cause
+ }
+
+ return &permanentError{cause: cause}
+}
+
+func (e *permanentError) Error() string {
+ return e.cause.Error()
+}
+
+func (e *permanentError) Unwrap() error {
+ return e.cause
+}
+
+func IsPermanent(err error) bool {
+ var target *permanentError
+
+ return errors.As(err, &target)
+}
+
+func HTTPStatusError(platform, endpoint string, status int) error {
+ err := fmt.Errorf("%s %s endpoint returned HTTP %d", platform, endpoint, status)
+ if status >= http.StatusBadRequest && status < http.StatusInternalServerError && status != http.StatusRequestTimeout && status != http.StatusTooManyRequests {
+ return Permanent(err)
+ }
+
+ return err
+}
diff --git a/pkg/channel/errors_test.go b/pkg/channel/errors_test.go
new file mode 100644
index 0000000..15cc0c4
--- /dev/null
+++ b/pkg/channel/errors_test.go
@@ -0,0 +1,20 @@
+package channel
+
+import "testing"
+
+func TestHTTPStatusErrorClassification(t *testing.T) {
+ for _, test := range []struct {
+ status int
+ permanent bool
+ }{
+ {status: 400, permanent: true},
+ {status: 401, permanent: true},
+ {status: 408, permanent: false},
+ {status: 429, permanent: false},
+ {status: 500, permanent: false},
+ } {
+ if actual := IsPermanent(HTTPStatusError("test", "message", test.status)); actual != test.permanent {
+ t.Fatalf("HTTP %d permanent = %v, want %v", test.status, actual, test.permanent)
+ }
+ }
+}
diff --git a/pkg/channel/fake/fake.go b/pkg/channel/fake/fake.go
deleted file mode 100644
index 70b59be..0000000
--- a/pkg/channel/fake/fake.go
+++ /dev/null
@@ -1,92 +0,0 @@
-// Package fake provides a deterministic Channel adapter for development and
-// end-to-end tests. Applications must register it explicitly.
-package fake
-
-import (
- "context"
- "errors"
- "strings"
- "time"
-
- "github.com/mooncode-ai/mooncode/pkg/channel"
-)
-
-type Factory struct{}
-
-func NewFactory() *Factory { return &Factory{} }
-func (*Factory) Type() string { return "fake" }
-func (*Factory) Descriptor() channel.Descriptor {
- return channel.Descriptor{
- Type: "fake", DisplayName: "Development fixture", Icon: "test-tube",
- Capabilities: []string{"receive"},
- Fields: []channel.ConfigField{
- {Name: "message", Type: "string", Required: false, Help: "Text emitted when the runtime starts."},
- {Name: "event_id", Type: "string", Required: false, Help: "Stable event ID used to verify deduplication."},
- },
- AttributeSchema: channel.AttributeSchema{Fields: []channel.AttributeField{{Key: "fixture", MaxBytes: 16, Enum: []string{"compose"}}}},
- }
-}
-func (*Factory) Validate(config channel.InstanceConfig) error {
- if strings.TrimSpace(config.AccountID) == "" {
- return errors.New("fake channel account ID is required")
- }
- if len(config.SenderAllowList) == 0 {
- return errors.New("fake channel sender allow list is required")
- }
- for _, key := range []string{"message", "event_id"} {
- if value, ok := config.Values[key]; ok {
- if _, valid := value.(string); !valid {
- return errors.New("fake channel values must be strings")
- }
- }
- }
- return nil
-}
-func (factory *Factory) New(config channel.InstanceConfig, sink channel.InboundSink, opts ...channel.ChannelOption) (channel.Channel, error) {
- if err := factory.Validate(config); err != nil {
- return nil, err
- }
- options := channel.ResolveChannelOptions(opts...)
- base, err := channel.NewBaseChannel(factory.Type(), config, sink,
- channel.WithAttributeSchema(factory.Descriptor().AttributeSchema),
- channel.WithInboundMiddlewares(options.InboundMiddleware...),
- )
- if err != nil {
- return nil, err
- }
- message := stringValue(config.Values, "message", "MoonCode Compose message")
- eventID := stringValue(config.Values, "event_id", "mooncode-compose-event")
- return &runtime{base: base, message: message, eventID: eventID}, nil
-}
-
-type runtime struct {
- base *channel.BaseChannel
- message string
- eventID string
-}
-
-func (*runtime) Type() string { return "fake" }
-func (instance *runtime) Run(ctx context.Context) error {
- _, err := instance.base.Accept(ctx, channel.InboundMessage{
- ExternalEventID: instance.eventID, ExternalMessageID: instance.eventID,
- Conversation: channel.ConversationRef{ID: "mooncode-compose-conversation", Type: "direct"},
- Sender: channel.SenderInfo{PlatformID: "mooncode-compose-sender", DisplayName: "Compose Fixture"},
- Content: channel.MessageContent{Type: "text", Text: instance.message},
- OccurredAt: time.Now().UTC(), Attributes: map[string]string{"fixture": "compose", "discarded": "unsafe"},
- })
- if err != nil {
- return err
- }
- <-ctx.Done()
- return nil
-}
-
-func stringValue(values map[string]any, key, fallback string) string {
- if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
- return strings.TrimSpace(value)
- }
- return fallback
-}
-
-var _ channel.Factory = (*Factory)(nil)
-var _ channel.Channel = (*runtime)(nil)
diff --git a/pkg/channel/fake/fake_test.go b/pkg/channel/fake/fake_test.go
deleted file mode 100644
index 4387970..0000000
--- a/pkg/channel/fake/fake_test.go
+++ /dev/null
@@ -1,33 +0,0 @@
-package fake
-
-import (
- "context"
- "testing"
-
- "github.com/mooncode-ai/mooncode/pkg/channel"
- "github.com/stretchr/testify/require"
-)
-
-func TestRuntimeEmitsNormalizedDeterministicMessage(t *testing.T) {
- sink := &recordingSink{accepted: make(chan channel.InboundMessage, 1)}
- runtime, err := NewFactory().New(channel.InstanceConfig{
- AccountID: "fixture", Values: map[string]any{"message": "hello", "event_id": "event-one"}, SenderAllowList: []string{"*"},
- }, sink)
- require.NoError(t, err)
- ctx, cancel := context.WithCancel(t.Context())
- done := make(chan error, 1)
- go func() { done <- runtime.Run(ctx) }()
- message := <-sink.accepted
- require.Equal(t, "fake", message.ChannelType)
- require.Equal(t, "fake:mooncode-compose-sender", message.Sender.CanonicalID)
- require.Equal(t, map[string]string{"fixture": "compose"}, message.Attributes)
- cancel()
- require.NoError(t, <-done)
-}
-
-type recordingSink struct{ accepted chan channel.InboundMessage }
-
-func (sink *recordingSink) Accept(_ context.Context, message channel.InboundMessage) (channel.AcceptResult, error) {
- sink.accepted <- message
- return channel.Accepted, nil
-}
diff --git a/pkg/channel/feishu/connection.go b/pkg/channel/feishu/connection.go
new file mode 100644
index 0000000..7b7faa6
--- /dev/null
+++ b/pkg/channel/feishu/connection.go
@@ -0,0 +1,156 @@
+package feishu
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+
+ lark "github.com/larksuite/oapi-sdk-go/v3"
+ larkdispatcher "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
+ larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
+ larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+)
+
+const connectionTimeout = 30 * time.Second
+
+type websocket interface {
+ Start(context.Context) error
+}
+
+type connection struct {
+ *sender
+
+ mu sync.Mutex
+ cancel context.CancelFunc
+}
+
+func (c *connection) Start(ctx context.Context, handler channel.Handler) error {
+ if handler == nil {
+ return errors.New("feishu inbound handler is required")
+ }
+
+ c.mu.Lock()
+ if c.cancel != nil {
+ c.mu.Unlock()
+ return errors.New("feishu connection is already started")
+ }
+ runContext, cancel := context.WithCancel(ctx)
+ c.cancel = cancel
+ c.mu.Unlock()
+
+ botOpenID, _ := c.loadBotOpenID(runContext)
+ dispatcher := larkdispatcher.NewEventDispatcher("", "").
+ OnP2MessageReceiveV1(func(eventContext context.Context, event *larkim.P2MessageReceiveV1) error {
+ message, ok := parseInbound(event, botOpenID, time.Now)
+ if !ok {
+ return nil
+ }
+
+ return handler(eventContext, message)
+ })
+
+ ready := make(chan struct{}, 1)
+ failed := make(chan error, 1)
+ domain := lark.FeishuBaseUrl
+ if c.host == lark.LarkBaseUrl {
+ domain = lark.LarkBaseUrl
+ }
+ client := larkws.NewClient(
+ c.appID,
+ c.appSecret,
+ larkws.WithEventHandler(dispatcher),
+ larkws.WithDomain(domain),
+ larkws.WithOnReady(func() { signalReady(ready) }),
+ larkws.WithOnError(func(err error) { signalError(failed, err) }),
+ )
+ go func(client websocket) {
+ if err := client.Start(runContext); err != nil {
+ signalError(failed, err)
+ }
+ }(client)
+
+ timer := time.NewTimer(connectionTimeout)
+ defer timer.Stop()
+ select {
+ case <-ready:
+ return nil
+ case err := <-failed:
+ _ = c.Close()
+ return fmt.Errorf("start feishu websocket: %w", err)
+ case <-timer.C:
+ _ = c.Close()
+ return errors.New("start feishu websocket: timed out")
+ case <-ctx.Done():
+ _ = c.Close()
+ return ctx.Err()
+ }
+}
+
+func (c *connection) Close() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if c.cancel != nil {
+ c.cancel()
+ c.cancel = nil
+ }
+
+ return nil
+}
+
+func (c *connection) loadBotOpenID(ctx context.Context) (string, error) {
+ token, err := c.tenantAccessToken(ctx)
+ if err != nil {
+ return "", err
+ }
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.host+"/open-apis/bot/v3/info", nil)
+ if err != nil {
+ return "", err
+ }
+ request.Header.Set("Authorization", "Bearer "+token)
+ response, err := c.client.Do(request)
+ if err != nil {
+ return "", err
+ }
+ defer func() { _ = response.Body.Close() }()
+ if response.StatusCode/100 != 2 {
+ return "", fmt.Errorf("feishu bot endpoint returned HTTP %d", response.StatusCode)
+ }
+ var result struct {
+ Code int `json:"code"`
+ Bot struct {
+ OpenID string `json:"open_id"`
+ } `json:"bot"`
+ }
+ if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
+ return "", err
+ }
+ if result.Code != 0 || result.Bot.OpenID == "" {
+ return "", fmt.Errorf("feishu bot request failed: code %d", result.Code)
+ }
+
+ return result.Bot.OpenID, nil
+}
+
+func signalReady(destination chan<- struct{}) {
+ select {
+ case destination <- struct{}{}:
+ default:
+ }
+}
+
+func signalError(destination chan<- error, err error) {
+ if err == nil {
+ return
+ }
+ select {
+ case destination <- err:
+ default:
+ }
+}
diff --git a/pkg/channel/feishu/feishu.go b/pkg/channel/feishu/feishu.go
index d43ab27..59b73d1 100644
--- a/pkg/channel/feishu/feishu.go
+++ b/pkg/channel/feishu/feishu.go
@@ -1,169 +1,114 @@
-// Package feishu implements a channel.Factory with the official Lark SDK.
package feishu
import (
+ "bytes"
"context"
"encoding/json"
"errors"
- "strconv"
+ "fmt"
+ "net/http"
"strings"
"time"
- lark "github.com/larksuite/oapi-sdk-go/v3"
- "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
- larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
- larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
- "github.com/mooncode-ai/mooncode/pkg/channel"
+ "github.com/fuchencong/mooncode/pkg/channel"
)
-type Factory struct{}
-type FactoryOption func(*Factory)
+type Option func(*Factory)
+type Factory struct{ client *http.Client }
-func NewFactory(opts ...FactoryOption) *Factory {
- factory := &Factory{}
- for _, option := range opts {
- if option != nil {
- option(factory)
- }
+func WithHTTPClient(client *http.Client) Option {
+ return func(factory *Factory) { factory.client = client }
+}
+func New(options ...Option) *Factory {
+ value := &Factory{client: &http.Client{Timeout: 15 * time.Second}}
+ for _, option := range options {
+ option(value)
}
- return factory
+ return value
}
func (*Factory) Type() string { return "feishu" }
-func (*Factory) Descriptor() channel.Descriptor {
- return channel.Descriptor{Type: "feishu", DisplayName: "Feishu / Lark", Icon: "message-square", Capabilities: []string{"receive"}, DocumentationURL: "https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case", Fields: []channel.ConfigField{{Name: "app_id", Type: "string", Required: true, Help: "Application ID"}, {Name: "app_secret", Type: "password", Required: true, Secret: true}, {Name: "verification_token", Type: "password", Secret: true}, {Name: "encrypt_key", Type: "password", Secret: true}, {Name: "is_lark", Type: "boolean"}}, AttributeSchema: channel.AttributeSchema{Fields: []channel.AttributeField{{Key: "tenant_key", MaxBytes: 128}}}}
-}
-func (*Factory) Validate(config channel.InstanceConfig) error {
- if stringValue(config.Values, "app_id") == "" || config.Secrets["app_secret"] == "" {
- return errors.New("feishu app_id and app_secret are required")
+func (f *Factory) New(config map[string]any, appSecret string) (channel.Connection, error) {
+ appID, _ := config["app_id"].(string)
+ receiveID, _ := config["receive_id"].(string)
+ receiveIDType, _ := config["receive_id_type"].(string)
+ if receiveIDType == "" {
+ receiveIDType = "chat_id"
}
- return nil
-}
-func (factory *Factory) New(config channel.InstanceConfig, sink channel.InboundSink, opts ...channel.ChannelOption) (channel.Channel, error) {
- if err := factory.Validate(config); err != nil {
- return nil, err
+ if strings.TrimSpace(appID) == "" || strings.TrimSpace(appSecret) == "" || strings.TrimSpace(receiveID) == "" {
+ return nil, errors.New("feishu app ID, app secret, and receive ID are required")
}
- options := channel.ResolveChannelOptions(opts...)
- base, err := channel.NewBaseChannel(factory.Type(), config, sink, channel.WithAttributeSchema(factory.Descriptor().AttributeSchema), channel.WithInboundMiddlewares(options.InboundMiddleware...))
- if err != nil {
- return nil, err
+ host := "https://open.feishu.cn"
+ if isLark, _ := config["is_lark"].(bool); isLark {
+ host = "https://open.larksuite.com"
}
- return &runtime{config: config, base: base}, nil
+ return &connection{sender: &sender{client: f.client, host: host, appID: appID, appSecret: appSecret, receiveID: receiveID, receiveIDType: receiveIDType}}, nil
}
-type runtime struct {
- config channel.InstanceConfig
- base *channel.BaseChannel
+type sender struct {
+ client *http.Client
+ host string
+ appID string
+ appSecret string
+ receiveID string
+ receiveIDType string
}
-func (*runtime) Type() string { return "feishu" }
-func (r *runtime) Run(ctx context.Context) error {
- dispatch := dispatcher.NewEventDispatcher(r.config.Secrets["verification_token"], r.config.Secrets["encrypt_key"]).OnP2MessageReceiveV1(r.receive)
- options := []larkws.ClientOption{larkws.WithEventHandler(dispatch), larkws.WithAutoReconnect(true)}
- if boolValue(r.config.Values, "is_lark") {
- options = append(options, larkws.WithDomain(lark.LarkBaseUrl))
+func (s *sender) Send(ctx context.Context, message channel.Message) error {
+ token, err := s.tenantAccessToken(ctx)
+ if err != nil {
+ return err
}
- client := larkws.NewClient(stringValue(r.config.Values, "app_id"), r.config.Secrets["app_secret"], options...)
- result := make(chan error, 1)
- // The official v3.9.9 SDK enters a permanent select after connecting, and
- // Close does not unblock Start. Keep the unavoidable SDK goroutine isolated
- // from MoonCode's supervisor until upstream provides a waitable API.
- go func() { result <- client.Start(ctx) }()
- select {
- case err := <-result:
+
+ receiveID, receiveIDType := s.receiveID, s.receiveIDType
+ if strings.TrimSpace(message.TargetID) != "" {
+ receiveID, receiveIDType = strings.TrimSpace(message.TargetID), "chat_id"
+ }
+ content, _ := json.Marshal(map[string]string{"text": message.Text})
+ body, _ := json.Marshal(map[string]any{"receive_id": receiveID, "msg_type": "text", "content": string(content)})
+ endpoint := fmt.Sprintf("%s/open-apis/im/v1/messages?receive_id_type=%s", s.host, receiveIDType)
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
return err
- case <-ctx.Done():
- client.Close()
- return nil
}
-}
-func (r *runtime) receive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
- message, err := normalize(event)
+ request.Header.Set("Authorization", "Bearer "+token)
+ request.Header.Set("Content-Type", "application/json")
+ response, err := s.client.Do(request)
if err != nil {
return err
}
- _, err = r.base.Accept(ctx, message)
- if errors.Is(err, channel.ErrRejected) {
- return nil
+ defer func() { _ = response.Body.Close() }()
+ if response.StatusCode/100 != 2 {
+ return channel.HTTPStatusError("feishu", "message", response.StatusCode)
}
- return err
+ return nil
}
-func normalize(event *larkim.P2MessageReceiveV1) (channel.InboundMessage, error) {
- if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
- return channel.InboundMessage{}, channel.ErrInvalidMessage
- }
- source := event.Event.Message
- messageID := pointer(source.MessageId)
- senderID := pointer(event.Event.Sender.SenderId.OpenId)
- if senderID == "" {
- senderID = pointer(event.Event.Sender.SenderId.UserId)
- }
- if senderID == "" {
- senderID = pointer(event.Event.Sender.SenderId.UnionId)
- }
- eventID, tenant := "", pointer(event.Event.Sender.TenantKey)
- if event.EventV2Base != nil && event.EventV2Base.Header != nil {
- eventID, tenant = event.EventV2Base.Header.EventID, event.EventV2Base.Header.TenantKey
- }
- if eventID == "" {
- eventID = "message:" + messageID
- }
- conversationType := "group"
- if pointer(source.ChatType) == "p2p" {
- conversationType = "direct"
+func (s *sender) tenantAccessToken(ctx context.Context) (string, error) {
+ tokenBody, _ := json.Marshal(map[string]string{"app_id": s.appID, "app_secret": s.appSecret})
+ tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, s.host+"/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(tokenBody))
+ if err != nil {
+ return "", err
}
- occurredAt := time.Now().UTC()
- if milliseconds, err := strconv.ParseInt(pointer(source.CreateTime), 10, 64); err == nil {
- occurredAt = time.UnixMilli(milliseconds).UTC()
+ tokenRequest.Header.Set("Content-Type", "application/json")
+ tokenResponse, err := s.client.Do(tokenRequest)
+ if err != nil {
+ return "", err
}
- contentType, text := pointer(source.MessageType), extractText(pointer(source.Content))
- if contentType == "" {
- contentType = "unknown"
+ defer func() { _ = tokenResponse.Body.Close() }()
+ if tokenResponse.StatusCode/100 != 2 {
+ return "", channel.HTTPStatusError("feishu", "token", tokenResponse.StatusCode)
}
- return channel.InboundMessage{ExternalEventID: eventID, ExternalMessageID: messageID, Conversation: channel.ConversationRef{ID: pointer(source.ChatId), Type: conversationType, TopicID: pointer(source.ThreadId)}, Sender: channel.SenderInfo{PlatformID: senderID}, Content: channel.MessageContent{Type: contentType, Text: text}, Mentioned: len(source.Mentions) > 0, ReplyToMessageID: pointer(source.ParentId), OccurredAt: occurredAt, Attributes: map[string]string{"tenant_key": tenant}}, nil
-}
-func extractText(raw string) string {
- var value any
- if json.Unmarshal([]byte(raw), &value) != nil {
- return ""
+ var token struct {
+ Code int `json:"code"`
+ Message string `json:"msg"`
+ TenantAccessToken string `json:"tenant_access_token"`
}
- parts := make([]string, 0)
- collectStrings(value, &parts)
- return strings.TrimSpace(strings.Join(parts, " "))
-}
-func collectStrings(value any, parts *[]string) {
- switch typed := value.(type) {
- case string:
- if strings.TrimSpace(typed) != "" {
- *parts = append(*parts, typed)
- }
- case []any:
- for _, item := range typed {
- collectStrings(item, parts)
- }
- case map[string]any:
- if text, ok := typed["text"].(string); ok {
- collectStrings(text, parts)
- return
- }
- for key, item := range typed {
- if key != "tag" && key != "style" {
- collectStrings(item, parts)
- }
- }
+ if err := json.NewDecoder(tokenResponse.Body).Decode(&token); err != nil {
+ return "", err
}
-}
-func pointer(value *string) string {
- if value == nil {
- return ""
+ if token.Code != 0 || token.TenantAccessToken == "" {
+ return "", channel.Permanent(fmt.Errorf("feishu token request failed: code %d", token.Code))
}
- return *value
-}
-func stringValue(values map[string]any, key string) string {
- value, _ := values[key].(string)
- return strings.TrimSpace(value)
-}
-func boolValue(values map[string]any, key string) bool { value, _ := values[key].(bool); return value }
-var _ channel.Factory = (*Factory)(nil)
-var _ channel.Channel = (*runtime)(nil)
+ return token.TenantAccessToken, nil
+}
diff --git a/pkg/channel/feishu/feishu_test.go b/pkg/channel/feishu/feishu_test.go
index 0e635d1..49fb4bc 100644
--- a/pkg/channel/feishu/feishu_test.go
+++ b/pkg/channel/feishu/feishu_test.go
@@ -1,22 +1,69 @@
package feishu
import (
- "encoding/json"
+ "context"
+ "io"
+ "net/http"
+ "strings"
"testing"
- larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
- "github.com/stretchr/testify/require"
+ "github.com/fuchencong/mooncode/pkg/channel"
)
-func TestNormalizeMessageFixture(t *testing.T) {
- fixture := []byte(`{"schema":"2.0","header":{"event_id":"evt-1","tenant_key":"tenant-1"},"event":{"sender":{"sender_id":{"open_id":"ou-1"},"tenant_key":"tenant-1"},"message":{"message_id":"om-1","chat_id":"oc-1","chat_type":"p2p","message_type":"text","content":"{\"text\":\"hello moon\"}","create_time":"1700000000000"}}}`)
- var event larkim.P2MessageReceiveV1
- require.NoError(t, json.Unmarshal(fixture, &event))
- message, err := normalize(&event)
- require.NoError(t, err)
- require.Equal(t, "evt-1", message.ExternalEventID)
- require.Equal(t, "ou-1", message.Sender.PlatformID)
- require.Equal(t, "direct", message.Conversation.Type)
- require.Equal(t, "hello moon", message.Content.Text)
- require.Equal(t, "tenant-1", message.Attributes["tenant_key"])
+type roundTrip func(*http.Request) (*http.Response, error)
+
+func (fn roundTrip) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
+
+func TestSenderUsesAppCredentialAndChatID(t *testing.T) {
+ requests := 0
+ client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) {
+ requests++
+ body, _ := io.ReadAll(request.Body)
+ if requests == 1 {
+ if !strings.Contains(string(body), `"app_id":"app-id"`) || !strings.Contains(string(body), `"app_secret":"secret"`) {
+ t.Fatalf("unexpected token body: %s", body)
+ }
+ return response(`{"code":0,"tenant_access_token":"tenant-token"}`), nil
+ }
+ if request.Header.Get("Authorization") != "Bearer tenant-token" || !strings.Contains(request.URL.RawQuery, "receive_id_type=chat_id") || !strings.Contains(string(body), `"receive_id":"chat-id"`) {
+ t.Fatalf("unexpected message request: %s %s %s", request.URL, request.Header, body)
+ }
+ return response(`{"code":0}`), nil
+ })}
+ sender, err := New(WithHTTPClient(client)).New(map[string]any{"app_id": "app-id", "receive_id": "chat-id"}, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sender.Send(context.Background(), channel.Message{Text: "done"}); err != nil {
+ t.Fatal(err)
+ }
+ if requests != 2 {
+ t.Fatalf("expected two requests, got %d", requests)
+ }
+}
+
+func TestSenderOverridesConfiguredTargetForCommandReply(t *testing.T) {
+ requests := 0
+ client := &http.Client{Transport: roundTrip(func(request *http.Request) (*http.Response, error) {
+ requests++
+ if requests == 1 {
+ return response(`{"code":0,"tenant_access_token":"tenant-token"}`), nil
+ }
+ body, _ := io.ReadAll(request.Body)
+ if !strings.Contains(string(body), `"receive_id":"inbound-chat"`) || !strings.Contains(request.URL.RawQuery, "receive_id_type=chat_id") {
+ t.Fatalf("command reply did not target inbound chat: %s %s", request.URL, body)
+ }
+ return response(`{"code":0}`), nil
+ })}
+ sender, err := New(WithHTTPClient(client)).New(map[string]any{"app_id": "app-id", "receive_id": "configured-chat"}, "secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sender.Send(context.Background(), channel.Message{Text: "reply", TargetID: "inbound-chat", TargetType: "group"}); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func response(body string) *http.Response {
+ return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body))}
}
diff --git a/pkg/channel/feishu/inbound.go b/pkg/channel/feishu/inbound.go
new file mode 100644
index 0000000..b7851f5
--- /dev/null
+++ b/pkg/channel/feishu/inbound.go
@@ -0,0 +1,94 @@
+package feishu
+
+import (
+ "encoding/json"
+ "strconv"
+ "strings"
+ "time"
+
+ larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
+
+ "github.com/fuchencong/mooncode/pkg/channel"
+)
+
+func parseInbound(event *larkim.P2MessageReceiveV1, botOpenID string, now func() time.Time) (channel.InboundMessage, bool) {
+ if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil {
+ return channel.InboundMessage{}, false
+ }
+ message := event.Event.Message
+ if stringValue(message.MessageType) != "text" {
+ return channel.InboundMessage{}, false
+ }
+
+ var content struct {
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal([]byte(stringValue(message.Content)), &content); err != nil {
+ return channel.InboundMessage{}, false
+ }
+ externalID := strings.TrimSpace(stringValue(message.MessageId))
+ conversationID := strings.TrimSpace(stringValue(message.ChatId))
+ senderID := feishuSenderID(event.Event.Sender)
+ if externalID == "" || conversationID == "" || senderID == "" || strings.TrimSpace(content.Text) == "" {
+ return channel.InboundMessage{}, false
+ }
+
+ mentioned := false
+ text := content.Text
+ for _, mention := range message.Mentions {
+ if mention == nil || mention.Id == nil || mention.Id.OpenId == nil || *mention.Id.OpenId != botOpenID || botOpenID == "" {
+ continue
+ }
+ mentioned = true
+ text = strings.ReplaceAll(text, stringValue(mention.Key), "")
+ }
+
+ conversationType := "group"
+ if stringValue(message.ChatType) == "p2p" {
+ conversationType = "direct"
+ }
+
+ return channel.InboundMessage{
+ ExternalID: externalID,
+ ConversationID: conversationID,
+ ConversationType: conversationType,
+ SenderCanonicalID: "feishu:" + senderID,
+ Text: strings.TrimSpace(text),
+ Mentioned: mentioned,
+ OccurredAt: milliseconds(stringValue(message.CreateTime), now),
+ }, true
+}
+
+func feishuSenderID(sender *larkim.EventSender) string {
+ if sender == nil || sender.SenderId == nil {
+ return ""
+ }
+ if sender.SenderId.OpenId != nil && strings.TrimSpace(*sender.SenderId.OpenId) != "" {
+ return strings.TrimSpace(*sender.SenderId.OpenId)
+ }
+ if sender.SenderId.UserId != nil && strings.TrimSpace(*sender.SenderId.UserId) != "" {
+ return strings.TrimSpace(*sender.SenderId.UserId)
+ }
+ if sender.SenderId.UnionId != nil {
+ return strings.TrimSpace(*sender.SenderId.UnionId)
+ }
+
+ return ""
+}
+
+func milliseconds(value string, now func() time.Time) time.Time {
+ parsed, err := strconv.ParseInt(value, 10, 64)
+ if err != nil || parsed <= 0 {
+ return now().UTC()
+ }
+
+ return time.UnixMilli(parsed).UTC()
+}
+
+func stringValue(value *string) string {
+ if value == nil {
+ return ""
+ }
+
+ return *value
+}
diff --git a/pkg/channel/feishu/inbound_test.go b/pkg/channel/feishu/inbound_test.go
new file mode 100644
index 0000000..3ee3bbb
--- /dev/null
+++ b/pkg/channel/feishu/inbound_test.go
@@ -0,0 +1,54 @@
+package feishu
+
+import (
+ "testing"
+ "time"
+
+ larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
+)
+
+func TestParseInboundTextMessage(t *testing.T) {
+ messageID := "om_123"
+ chatID := "oc_123"
+ chatType := "group"
+ messageType := "text"
+ content := `{"text":"@_user_1 analyze this"}`
+ createdAt := "1710000000123"
+ openID := "ou_sender"
+ botOpenID := "ou_bot"
+ mentionKey := "@_user_1"
+ event := &larkim.P2MessageReceiveV1{Event: &larkim.P2MessageReceiveV1Data{
+ Sender: &larkim.EventSender{SenderId: &larkim.UserId{OpenId: &openID}},
+ Message: &larkim.EventMessage{
+ MessageId: &messageID, ChatId: &chatID, ChatType: &chatType,
+ MessageType: &messageType, Content: &content, CreateTime: &createdAt,
+ Mentions: []*larkim.MentionEvent{{Key: &mentionKey, Id: &larkim.UserId{OpenId: &botOpenID}}},
+ },
+ }}
+
+ message, ok := parseInbound(event, botOpenID, func() time.Time { return time.Time{} })
+ if !ok {
+ t.Fatal("expected message to be parsed")
+ }
+ if message.ExternalID != messageID || message.ConversationID != chatID || message.ConversationType != "group" {
+ t.Fatalf("unexpected message identity: %+v", message)
+ }
+ if message.SenderCanonicalID != "feishu:"+openID || !message.Mentioned || message.Text != "analyze this" {
+ t.Fatalf("unexpected normalized message: %+v", message)
+ }
+ if want := time.UnixMilli(1710000000123).UTC(); !message.OccurredAt.Equal(want) {
+ t.Fatalf("occurred at = %s, want %s", message.OccurredAt, want)
+ }
+}
+
+func TestParseInboundRejectsUnsupportedMessage(t *testing.T) {
+ messageType := "image"
+ event := &larkim.P2MessageReceiveV1{Event: &larkim.P2MessageReceiveV1Data{
+ Sender: &larkim.EventSender{},
+ Message: &larkim.EventMessage{MessageType: &messageType},
+ }}
+
+ if _, ok := parseInbound(event, "", time.Now); ok {
+ t.Fatal("expected image message to be ignored")
+ }
+}
diff --git a/pkg/channel/registry.go b/pkg/channel/registry.go
deleted file mode 100644
index bf5f644..0000000
--- a/pkg/channel/registry.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package channel
-
-import (
- "errors"
- "fmt"
- "sort"
- "strings"
-)
-
-type RegistryOption func(*Registry) error
-
-func WithFactory(factory Factory) RegistryOption {
- return func(registry *Registry) error { return registry.Register(factory) }
-}
-
-type Registry struct{ factories map[string]Factory }
-
-func NewRegistry(opts ...RegistryOption) (*Registry, error) {
- registry := &Registry{factories: make(map[string]Factory)}
- for _, option := range opts {
- if option != nil {
- if err := option(registry); err != nil {
- return nil, err
- }
- }
- }
- return registry, nil
-}
-func (r *Registry) Register(factory Factory) error {
- if factory == nil {
- return errors.New("channel factory is required")
- }
- typeName := strings.ToLower(strings.TrimSpace(factory.Type()))
- descriptor := factory.Descriptor()
- if typeName == "" || descriptor.Type != typeName || strings.TrimSpace(descriptor.DisplayName) == "" {
- return errors.New("channel factory descriptor is invalid")
- }
- if _, exists := r.factories[typeName]; exists {
- return fmt.Errorf("register channel factory %q: duplicate type", typeName)
- }
- if err := validateDescriptor(descriptor); err != nil {
- return fmt.Errorf("register channel factory %q: %w", typeName, err)
- }
- r.factories[typeName] = factory
- return nil
-}
-func (r *Registry) Factory(channelType string) (Factory, bool) {
- factory, ok := r.factories[strings.ToLower(strings.TrimSpace(channelType))]
- return factory, ok
-}
-func (r *Registry) Descriptors() []Descriptor {
- result := make([]Descriptor, 0, len(r.factories))
- for _, factory := range r.factories {
- result = append(result, factory.Descriptor())
- }
- sort.Slice(result, func(i, j int) bool { return result[i].Type < result[j].Type })
- return result
-}
-func validateDescriptor(descriptor Descriptor) error {
- seenFields := make(map[string]struct{})
- for _, field := range descriptor.Fields {
- if field.Name == "" {
- return errors.New("config field name is required")
- }
- if _, exists := seenFields[field.Name]; exists {
- return fmt.Errorf("duplicate config field %q", field.Name)
- }
- seenFields[field.Name] = struct{}{}
- }
- seenAttributes := make(map[string]struct{})
- for _, field := range descriptor.AttributeSchema.Fields {
- if field.Key == "" || field.MaxBytes <= 0 {
- return errors.New("attribute field key and positive max bytes are required")
- }
- if _, exists := seenAttributes[field.Key]; exists {
- return fmt.Errorf("duplicate attribute field %q", field.Key)
- }
- seenAttributes[field.Key] = struct{}{}
- }
- return nil
-}
diff --git a/pkg/git/exec.go b/pkg/git/exec.go
deleted file mode 100644
index 2c5b236..0000000
--- a/pkg/git/exec.go
+++ /dev/null
@@ -1,222 +0,0 @@
-package git
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-)
-
-type ClientOption func(*ExecClient)
-
-func WithExecutable(path string) ClientOption {
- return func(client *ExecClient) {
- if strings.TrimSpace(path) != "" {
- client.executable = path
- }
- }
-}
-
-func WithDefaultTimeout(timeout time.Duration) ClientOption {
- return func(client *ExecClient) {
- if timeout > 0 {
- client.defaultTimeout = timeout
- }
- }
-}
-
-// WithHTTPSCAFile adds a CA bundle for HTTPS Git remotes. It is useful for
-// private Git servers with an organization-managed CA; system trust remains
-// the default when the option is not supplied.
-func WithHTTPSCAFile(path string) ClientOption {
- return func(client *ExecClient) {
- if strings.TrimSpace(path) != "" {
- client.httpsCAFile = path
- }
- }
-}
-
-type ExecClient struct {
- executable string
- defaultTimeout time.Duration
- httpsCAFile string
-}
-
-func NewClient(opts ...ClientOption) *ExecClient {
- client := &ExecClient{executable: "git", defaultTimeout: 10 * time.Minute}
- for _, option := range opts {
- if option != nil {
- option(client)
- }
- }
- return client
-}
-
-func (c *ExecClient) Probe(ctx context.Context, request ProbeRequest) error {
- if strings.TrimSpace(request.Remote) == "" {
- return errors.New("git probe requires remote")
- }
- timeout := request.Timeout
- if timeout <= 0 {
- timeout = c.defaultTimeout
- }
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
- env, cleanup, err := credentialEnvironment(request.Credential)
- if err != nil {
- return err
- }
- defer cleanup()
- if _, err := c.run(ctx, env, nil,
- "-c", "protocol.file.allow=never", "-c", "core.hooksPath=/dev/null",
- "ls-remote", "--exit-code", "--", request.Remote, "HEAD",
- ); err != nil {
- return fmt.Errorf("git remote probe failed: %w", err)
- }
- return nil
-}
-
-func (c *ExecClient) Fetch(ctx context.Context, request FetchRequest) (FetchResult, error) {
- if err := validateFetchRequest(request); err != nil {
- return FetchResult{}, err
- }
- timeout := request.Timeout
- if timeout <= 0 {
- timeout = c.defaultTimeout
- }
- ctx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
-
- env, cleanup, err := credentialEnvironment(request.Credential)
- if err != nil {
- return FetchResult{}, err
- }
- defer cleanup()
-
- if _, err := c.run(ctx, nil, nil, "init", "--initial-branch=mooncode", "--", request.Directory); err != nil {
- return FetchResult{}, fmt.Errorf("initialize Git checkout: %w", err)
- }
- if _, err := c.run(ctx, nil, nil, "-C", request.Directory, "remote", "add", "origin", request.Remote); err != nil {
- return FetchResult{}, fmt.Errorf("configure Git remote: %w", err)
- }
- target := strings.TrimSpace(request.Ref)
- if target == "" {
- target = "HEAD"
- }
- fetchArgs := []string{
- "-c", "protocol.file.allow=never",
- "-c", "core.hooksPath=/dev/null",
- "-c", "submodule.recurse=false",
- "-c", "filter.lfs.smudge=",
- "-c", "filter.lfs.process=",
- "-c", "filter.lfs.required=false",
- "-C", request.Directory, "fetch", "--no-tags", "--depth", strconv.Itoa(request.Depth), "--", "origin", target,
- }
- if _, err := c.run(ctx, env, nil, fetchArgs...); err != nil {
- return FetchResult{}, fmt.Errorf("git fetch failed: %w", err)
- }
- if _, err := c.run(ctx, nil, nil,
- "-c", "core.hooksPath=/dev/null", "-C", request.Directory,
- "checkout", "--detach", "--force", "FETCH_HEAD",
- ); err != nil {
- return FetchResult{}, fmt.Errorf("checkout fetched commit: %w", err)
- }
- output, err := c.run(ctx, env, nil, "-C", request.Directory, "rev-parse", "HEAD")
- if err != nil {
- return FetchResult{}, fmt.Errorf("resolve fetched commit: %w", err)
- }
- commit := strings.TrimSpace(string(output))
- if len(commit) != 40 && len(commit) != 64 {
- return FetchResult{}, errors.New("git returned an invalid commit hash")
- }
- return FetchResult{CommitSHA: commit}, nil
-}
-
-func (c *ExecClient) run(ctx context.Context, extraEnv []string, stdout *bytes.Buffer, args ...string) ([]byte, error) {
- command := exec.CommandContext(ctx, c.executable, args...)
- command.Env = []string{
- "PATH=" + os.Getenv("PATH"),
- "LANG=C.UTF-8",
- "GIT_TERMINAL_PROMPT=0",
- "GIT_CONFIG_NOSYSTEM=1",
- "GIT_CONFIG_GLOBAL=/dev/null",
- "GIT_ALLOW_PROTOCOL=https",
- "GIT_LFS_SKIP_SMUDGE=1",
- }
- for _, name := range []string{"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"} {
- if value := os.Getenv(name); value != "" {
- command.Env = append(command.Env, name+"="+value)
- }
- }
- if c.httpsCAFile != "" {
- command.Env = append(command.Env, "GIT_SSL_CAINFO="+c.httpsCAFile)
- }
- command.Env = append(command.Env, extraEnv...)
- var output bytes.Buffer
- var stderr bytes.Buffer
- if stdout == nil {
- command.Stdout = &output
- } else {
- command.Stdout = stdout
- }
- command.Stderr = &stderr
- if err := command.Run(); err != nil {
- if ctx.Err() != nil {
- return nil, ctx.Err()
- }
- return nil, err
- }
- return output.Bytes(), nil
-}
-
-func validateFetchRequest(request FetchRequest) error {
- if strings.TrimSpace(request.Remote) == "" || strings.TrimSpace(request.Directory) == "" {
- return errors.New("git fetch requires remote and directory")
- }
- if !filepath.IsAbs(request.Directory) {
- return errors.New("git fetch directory must be absolute")
- }
- if request.Depth <= 0 || request.Depth > 1000 {
- return errors.New("git fetch depth must be between 1 and 1000")
- }
- if _, err := os.Stat(request.Directory); err == nil {
- return errors.New("git fetch directory already exists")
- } else if !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("inspect git fetch directory: %w", err)
- }
- return nil
-}
-
-func credentialEnvironment(credential Credential) ([]string, func(), error) {
- if credential.Secret() == "" {
- return nil, func() {}, nil
- }
- directory, err := os.MkdirTemp("", "mooncode-git-credential-")
- if err != nil {
- return nil, nil, fmt.Errorf("create Git credential helper directory: %w", err)
- }
- cleanup := func() { _ = os.RemoveAll(directory) }
- path := filepath.Join(directory, "askpass")
- script := "#!/bin/sh\ncase \"$1\" in\n *Username*) printf '%s\\n' \"$MOONCODE_GIT_USERNAME\" ;;\n *) printf '%s\\n' \"$MOONCODE_GIT_SECRET\" ;;\nesac\n"
- if err := os.WriteFile(path, []byte(script), 0o700); err != nil {
- cleanup()
- return nil, nil, fmt.Errorf("write Git credential helper: %w", err)
- }
- username := credential.Username()
- if username == "" {
- username = "git"
- }
- return []string{
- "GIT_ASKPASS=" + path,
- "MOONCODE_GIT_USERNAME=" + username,
- "MOONCODE_GIT_SECRET=" + credential.Secret(),
- }, cleanup, nil
-}
-
-var _ Client = (*ExecClient)(nil)
diff --git a/pkg/git/git.go b/pkg/git/git.go
deleted file mode 100644
index a3ab946..0000000
--- a/pkg/git/git.go
+++ /dev/null
@@ -1,73 +0,0 @@
-// Package git defines the Git execution boundary. Provider-specific remote and
-// credential policies belong to pkg/scm; process execution belongs to a Client.
-package git
-
-import (
- "context"
- "time"
-)
-
-type Credential struct {
- username string
- secret string
-}
-
-func NewCredential(username, secret string) Credential {
- return Credential{username: username, secret: secret}
-}
-
-func (credential Credential) Username() string {
- return credential.username
-}
-
-// Secret returns the credential secret to the execution adapter. Callers must
-// never log or persist the returned value.
-func (credential Credential) Secret() string {
- return credential.secret
-}
-
-func (credential Credential) String() string {
- return "[REDACTED]"
-}
-
-func (credential Credential) GoString() string {
- return "git.Credential{[REDACTED]}"
-}
-
-type FetchRequest struct {
- Provider string
- Remote string
- Ref string
- Directory string
- Credential Credential
- Depth int
- Timeout time.Duration
-}
-
-type FetchResult struct {
- CommitSHA string
-}
-
-type ProbeRequest struct {
- Provider string
- Remote string
- Credential Credential
- Timeout time.Duration
-}
-
-type Client interface {
- Probe(ctx context.Context, request ProbeRequest) error
- Fetch(ctx context.Context, request FetchRequest) (FetchResult, error)
-}
-
-type Wrapper func(Client) Client
-
-// Wrap applies wrappers in declaration order: the first wrapper is outermost.
-func Wrap(client Client, wrappers ...Wrapper) Client {
- for index := len(wrappers) - 1; index >= 0; index-- {
- if wrappers[index] != nil {
- client = wrappers[index](client)
- }
- }
- return client
-}
diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go
deleted file mode 100644
index c0c565f..0000000
--- a/pkg/git/git_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package git
-
-import (
- "context"
- "fmt"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestCredentialFormattingIsRedacted(t *testing.T) {
- credential := NewCredential("moon", "super-secret")
- require.Equal(t, "moon", credential.Username())
- require.Equal(t, "super-secret", credential.Secret())
- require.NotContains(t, fmt.Sprintf("%v", credential), "super-secret")
- require.NotContains(t, fmt.Sprintf("%#v", credential), "super-secret")
-}
-
-func TestWrapUsesDeclarationOrder(t *testing.T) {
- var calls []string
- wrapper := func(name string) Wrapper {
- return func(next Client) Client { return &recordingClient{name: name, next: next, calls: &calls} }
- }
- client := Wrap(&recordingClient{name: "client", calls: &calls}, wrapper("outer"), wrapper("inner"))
- require.NoError(t, client.Probe(t.Context(), ProbeRequest{}))
- require.Equal(t, []string{"outer:before", "inner:before", "client", "inner:after", "outer:after"}, calls)
-}
-
-type recordingClient struct {
- name string
- next Client
- calls *[]string
-}
-
-func (client *recordingClient) Probe(ctx context.Context, request ProbeRequest) error {
- if client.next == nil {
- *client.calls = append(*client.calls, client.name)
- return nil
- }
- *client.calls = append(*client.calls, client.name+":before")
- err := client.next.Probe(ctx, request)
- *client.calls = append(*client.calls, client.name+":after")
- return err
-}
-func (*recordingClient) Fetch(context.Context, FetchRequest) (FetchResult, error) {
- return FetchResult{}, nil
-}
diff --git a/pkg/git/integration_test.go b/pkg/git/integration_test.go
deleted file mode 100644
index df4f92e..0000000
--- a/pkg/git/integration_test.go
+++ /dev/null
@@ -1,139 +0,0 @@
-//go:build integration
-
-package git
-
-import (
- "bytes"
- "encoding/pem"
- "net/http"
- "net/http/httptest"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
- "time"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestHTTPSGitPublicAndPrivateRepositories(t *testing.T) {
- root := t.TempDir()
- createHTTPRepository(t, root, "public.git", "public checkout\n")
- createHTTPRepository(t, root, "private.git", "private checkout\n")
-
- server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
- if strings.HasPrefix(request.URL.Path, "/private.git/") {
- username, password, ok := request.BasicAuth()
- if !ok || username != "moon" || password != "secret-token" {
- writer.Header().Set("WWW-Authenticate", `Basic realm="MoonCode test"`)
- writer.WriteHeader(http.StatusUnauthorized)
- return
- }
- }
- serveGitHTTP(root, writer, request)
- }))
- t.Cleanup(server.Close)
- t.Setenv("NO_PROXY", "127.0.0.1,localhost")
-
- caFile := filepath.Join(t.TempDir(), "test-ca.pem")
- require.NoError(t, os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600))
- client := NewClient(WithDefaultTimeout(10*time.Second), WithHTTPSCAFile(caFile))
-
- t.Run("public", func(t *testing.T) {
- assertGitRoundTrip(t, client, server.URL+"/public.git", Credential{}, "public checkout\n")
- })
- t.Run("private rejects missing credential", func(t *testing.T) {
- err := client.Probe(t.Context(), ProbeRequest{Remote: server.URL + "/private.git"})
- require.Error(t, err)
- })
- t.Run("private", func(t *testing.T) {
- assertGitRoundTrip(t, client, server.URL+"/private.git", NewCredential("moon", "secret-token"), "private checkout\n")
- })
-}
-
-func serveGitHTTP(projectRoot string, writer http.ResponseWriter, request *http.Request) {
- command := exec.Command("git", "http-backend")
- command.Env = []string{
- "PATH=" + os.Getenv("PATH"),
- "GIT_CONFIG_NOSYSTEM=1",
- "GIT_CONFIG_GLOBAL=/dev/null",
- "GIT_PROJECT_ROOT=" + projectRoot,
- "GIT_HTTP_EXPORT_ALL=1",
- "REQUEST_METHOD=" + request.Method,
- "PATH_INFO=" + request.URL.Path,
- "QUERY_STRING=" + request.URL.RawQuery,
- "CONTENT_TYPE=" + request.Header.Get("Content-Type"),
- "CONTENT_LENGTH=" + strconv.FormatInt(request.ContentLength, 10),
- "SERVER_PROTOCOL=HTTP/1.1",
- "REMOTE_ADDR=" + request.RemoteAddr,
- }
- command.Stdin = request.Body
- output, err := command.Output()
- if err != nil {
- http.Error(writer, "Git fixture failed", http.StatusInternalServerError)
- return
- }
- parts := bytes.SplitN(output, []byte("\r\n\r\n"), 2)
- if len(parts) != 2 {
- http.Error(writer, "Git fixture returned an invalid response", http.StatusInternalServerError)
- return
- }
- status := http.StatusOK
- for _, line := range strings.Split(string(parts[0]), "\r\n") {
- name, value, found := strings.Cut(line, ":")
- if !found {
- continue
- }
- value = strings.TrimSpace(value)
- if strings.EqualFold(name, "Status") {
- if code, parseErr := strconv.Atoi(strings.Fields(value)[0]); parseErr == nil {
- status = code
- }
- continue
- }
- writer.Header().Add(name, value)
- }
- writer.WriteHeader(status)
- _, _ = writer.Write(parts[1])
-}
-
-func createHTTPRepository(t *testing.T, root, name, content string) {
- t.Helper()
- worktree := t.TempDir()
- runGit(t, worktree, "init", "--initial-branch=main")
- require.NoError(t, os.WriteFile(filepath.Join(worktree, "README.md"), []byte(content), 0o600))
- runGit(t, worktree, "add", "README.md")
- runGit(t, worktree, "-c", "user.name=MoonCode", "-c", "user.email=mooncode@example.invalid", "commit", "-m", "fixture")
- bare := filepath.Join(root, name)
- runGit(t, root, "clone", "--bare", worktree, bare)
- runGit(t, bare, "update-server-info")
-}
-
-func assertGitRoundTrip(t *testing.T, client Client, remote string, credential Credential, expected string) {
- t.Helper()
- require.NoError(t, client.Probe(t.Context(), ProbeRequest{Remote: remote, Credential: credential}))
- checkout := filepath.Join(t.TempDir(), "checkout")
- fetched, err := client.Fetch(t.Context(), FetchRequest{Provider: "generic", Remote: remote, Directory: checkout, Credential: credential, Depth: 1})
- require.NoError(t, err)
- require.NotEmpty(t, fetched.CommitSHA)
- gitConfig, err := os.ReadFile(filepath.Join(checkout, ".git", "config"))
- require.NoError(t, err)
- if credential.Secret() != "" {
- require.NotContains(t, string(gitConfig), credential.Secret())
- }
- require.NotContains(t, string(gitConfig), "mooncode-git-credential")
- contents, err := os.ReadFile(filepath.Join(checkout, "README.md"))
- require.NoError(t, err)
- require.Equal(t, expected, string(contents))
-}
-
-func runGit(t *testing.T, directory string, args ...string) {
- t.Helper()
- command := exec.Command("git", args...)
- command.Dir = directory
- command.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
- output, err := command.CombinedOutput()
- require.NoError(t, err, string(output))
-}
diff --git a/pkg/gitrepo/authentication_test.go b/pkg/gitrepo/authentication_test.go
new file mode 100644
index 0000000..8e69667
--- /dev/null
+++ b/pkg/gitrepo/authentication_test.go
@@ -0,0 +1,46 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestGitAuthenticationFailureIsPermanent(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "git")
+ if err := os.WriteFile(binary, []byte("#!/bin/sh\necho 'fatal: Authentication failed for repository' >&2\nexit 1\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ manager, err := NewManager(t.TempDir(), WithGitBinary(binary))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = manager.run(context.Background(), nil, "fetch")
+ if !errors.Is(err, ErrAuthentication) || !IsPermanent(err) {
+ t.Fatalf("authentication error = %v, want permanent ErrAuthentication", err)
+ }
+}
+
+func TestAuthenticationFailureMarkersAreSpecific(t *testing.T) {
+ for _, diagnostic := range []string{
+ "fatal: Authentication failed",
+ "The requested URL returned error: 403",
+ "remote: HTTP 401",
+ } {
+ if !authenticationFailure(diagnostic) {
+ t.Fatalf("authentication diagnostic was not recognized: %q", diagnostic)
+ }
+ }
+ for _, diagnostic := range []string{
+ "Could not resolve host",
+ "The requested URL returned error: 429",
+ "connection reset by peer",
+ } {
+ if authenticationFailure(diagnostic) {
+ t.Fatalf("transient diagnostic was classified as authentication: %q", diagnostic)
+ }
+ }
+}
diff --git a/pkg/gitrepo/capability.go b/pkg/gitrepo/capability.go
new file mode 100644
index 0000000..435e13f
--- /dev/null
+++ b/pkg/gitrepo/capability.go
@@ -0,0 +1,37 @@
+package gitrepo
+
+import (
+ "context"
+ "fmt"
+ "strings"
+)
+
+func (m *DefaultManager) validateGitConfigSupport(ctx context.Context, configs []GitConfig) error {
+ for _, config := range configs {
+ if config.Key != "http.curloptResolve" {
+ continue
+ }
+
+ available, err := m.run(ctx, nil, "help", "--config")
+ if err != nil {
+ return fmt.Errorf("inspect Git HTTP capabilities: %w", err)
+ }
+ if !gitConfigAvailable(available, config.Key) {
+ return fmt.Errorf("%w: installed Git does not support %s", ErrRemoteBlocked, config.Key)
+ }
+
+ return nil
+ }
+
+ return nil
+}
+
+func gitConfigAvailable(output, name string) bool {
+ for _, line := range strings.Split(output, "\n") {
+ if strings.TrimSpace(line) == name {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/gitrepo/capture.go b/pkg/gitrepo/capture.go
new file mode 100644
index 0000000..0ef9c13
--- /dev/null
+++ b/pkg/gitrepo/capture.go
@@ -0,0 +1,32 @@
+package gitrepo
+
+type outputCapture struct {
+ data []byte
+ limit int64
+ exceeded bool
+}
+
+func newOutputCapture(limit int64) *outputCapture {
+ return &outputCapture{data: make([]byte, 0, min(limit, 4096)), limit: limit}
+}
+
+func (c *outputCapture) Write(value []byte) (int, error) {
+ written := len(value)
+ remaining := c.limit - int64(len(c.data))
+ if remaining <= 0 {
+ c.exceeded = c.exceeded || written > 0
+
+ return written, nil
+ }
+ if int64(len(value)) > remaining {
+ value = value[:remaining]
+ c.exceeded = true
+ }
+ c.data = append(c.data, value...)
+
+ return written, nil
+}
+
+func (c *outputCapture) Exceeded() bool { return c.exceeded }
+
+func (c *outputCapture) String() string { return string(c.data) }
diff --git a/pkg/gitrepo/diagnostic.go b/pkg/gitrepo/diagnostic.go
new file mode 100644
index 0000000..1d5668d
--- /dev/null
+++ b/pkg/gitrepo/diagnostic.go
@@ -0,0 +1,66 @@
+package gitrepo
+
+import (
+ "encoding/base64"
+ "strings"
+)
+
+func redactGitDiagnostic(output string, environment []string) string {
+ for _, entry := range environment {
+ key, value, ok := strings.Cut(entry, "=")
+ if !ok || !strings.HasPrefix(key, "GIT_CONFIG_VALUE_") || !strings.HasPrefix(strings.ToLower(value), "authorization: basic ") {
+ continue
+ }
+
+ encoded := strings.TrimSpace(value[len("Authorization: Basic "):])
+ output = strings.ReplaceAll(output, value, "[REDACTED]")
+ output = strings.ReplaceAll(output, encoded, "[REDACTED]")
+
+ credential, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ continue
+ }
+ separator := strings.IndexByte(string(credential), ':')
+ if separator >= 0 {
+ output = strings.ReplaceAll(output, string(credential), "[REDACTED]")
+ if separator+1 < len(credential) {
+ output = strings.ReplaceAll(output, string(credential[separator+1:]), "[REDACTED]")
+ }
+ }
+ clear(credential)
+ }
+
+ return output
+}
+
+func safeGitError(output string) string {
+ output = strings.TrimSpace(output)
+ if output == "" {
+ return "no diagnostic output"
+ }
+ if len(output) > 1024 {
+ output = output[:1024]
+ }
+
+ return output
+}
+
+func authenticationFailure(diagnostic string) bool {
+ diagnostic = strings.ToLower(diagnostic)
+ for _, marker := range []string{
+ "authentication failed",
+ "invalid username or password",
+ "returned error: 401",
+ "returned error: 403",
+ "http 401",
+ "http 403",
+ "access denied",
+ "could not read username",
+ } {
+ if strings.Contains(diagnostic, marker) {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/pkg/gitrepo/environment.go b/pkg/gitrepo/environment.go
new file mode 100644
index 0000000..59f267a
--- /dev/null
+++ b/pkg/gitrepo/environment.go
@@ -0,0 +1,36 @@
+package gitrepo
+
+import "os"
+
+var proxyEnvironmentKeys = []string{
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "ALL_PROXY",
+ "NO_PROXY",
+ "http_proxy",
+ "https_proxy",
+ "all_proxy",
+ "no_proxy",
+}
+
+func defaultEnvironment() []string {
+ environment := []string{
+ "LANG=C",
+ "LC_ALL=C",
+ "PATH=/usr/local/bin:/usr/bin:/bin",
+ "TZ=UTC",
+ }
+ for _, key := range proxyEnvironmentKeys {
+ if value, ok := os.LookupEnv(key); ok {
+ environment = append(environment, key+"="+value)
+ }
+ }
+
+ return environment
+}
+
+func WithEnvironment(environment ...string) Option {
+ return func(manager *DefaultManager) {
+ manager.environment = append([]string(nil), environment...)
+ }
+}
diff --git a/pkg/gitrepo/integration_test.go b/pkg/gitrepo/integration_test.go
new file mode 100644
index 0000000..5be8151
--- /dev/null
+++ b/pkg/gitrepo/integration_test.go
@@ -0,0 +1,108 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestManagedBareRepositoryPinsImmutableSnapshots(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ source := filepath.Join(root, "source")
+ remote := filepath.Join(root, "remote.git")
+ mustGit(t, root, "init", "-b", "main", source)
+ mustGit(t, source, "config", "user.name", "MoonCode Test")
+ mustGit(t, source, "config", "user.email", "mooncode@example.com")
+ if err := os.WriteFile(filepath.Join(source, "content.txt"), []byte("first\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ mustGit(t, source, "add", "content.txt")
+ mustGit(t, source, "commit", "-m", "first")
+ firstSHA := strings.TrimSpace(mustGit(t, source, "rev-parse", "HEAD"))
+ mustGit(t, root, "clone", "--bare", source, remote)
+
+ manager, err := NewManager(filepath.Join(root, "managed"), allowTestRemotes())
+ if err != nil {
+ t.Fatal(err)
+ }
+ repositoryID, firstSnapshotID := uuid.New(), uuid.New()
+ first, err := manager.Provision(ctx, repositoryID, remote, "main", firstSnapshotID, Credential{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first.CommitSHA != firstSHA || first.MirrorSizeBytes <= 0 {
+ t.Fatalf("first snapshot = %s, want %s", first.CommitSHA, firstSHA)
+ }
+
+ if err := os.WriteFile(filepath.Join(source, "content.txt"), []byte("second\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ mustGit(t, source, "commit", "-am", "second")
+ secondSHA := strings.TrimSpace(mustGit(t, source, "rev-parse", "HEAD"))
+ mustGit(t, source, "push", remote, "main")
+ secondSnapshotID := uuid.New()
+ second, err := manager.Sync(ctx, repositoryID, remote, "main", secondSnapshotID, Credential{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if second.CommitSHA != secondSHA || second.CommitSHA == first.CommitSHA {
+ t.Fatal("expected refresh to resolve the new commit")
+ }
+ if _, err := manager.Sync(ctx, repositoryID, remote, "main", firstSnapshotID, Credential{}); err == nil {
+ t.Fatal("expected an existing snapshot ref to reject replacement")
+ }
+
+ pinnedFirst := strings.TrimSpace(mustGit(t, root, "--git-dir", manager.Path(repositoryID), "rev-parse", "refs/mooncode/snapshots/"+firstSnapshotID.String()))
+ if pinnedFirst != firstSHA {
+ t.Fatalf("historical snapshot moved to %s", pinnedFirst)
+ }
+ refs := mustGit(t, root, "--git-dir", manager.Path(repositoryID), "for-each-ref", "--format=%(refname)")
+ if strings.Contains(refs, "refs/heads/") || !strings.Contains(refs, "refs/mooncode/remotes/origin/heads/main") {
+ t.Fatalf("unexpected managed refs:\n%s", refs)
+ }
+ if _, err := manager.resolve(ctx, manager.Path(repositoryID), "main~1"); !errors.Is(err, ErrRefInvalid) {
+ t.Fatalf("revision expression error = %v, want ErrRefInvalid", err)
+ }
+ if _, err := manager.resolve(ctx, manager.Path(repositoryID), "refs/heads/missing"); !errors.Is(err, ErrRevisionNotFound) {
+ t.Fatalf("missing full ref error = %v, want ErrRevisionNotFound", err)
+ }
+
+ checkout, cleanup, err := manager.Checkout(ctx, repositoryID, firstSHA)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := os.ReadFile(filepath.Join(checkout, "content.txt"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(body) != "first\n" {
+ t.Fatalf("historical checkout contains %q", body)
+ }
+ if err := cleanup(); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func mustGit(t *testing.T, directory string, args ...string) string {
+ t.Helper()
+ command := exec.Command("git", args...)
+ command.Dir = directory
+ output, err := command.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, output)
+ }
+ return string(output)
+}
+
+func allowTestRemotes() Option {
+ return WithRemoteValidator(RemoteValidatorFunc(func(context.Context, string) ([]GitConfig, error) {
+ return nil, nil
+ }))
+}
diff --git a/pkg/gitrepo/janitor.go b/pkg/gitrepo/janitor.go
new file mode 100644
index 0000000..8a986eb
--- /dev/null
+++ b/pkg/gitrepo/janitor.go
@@ -0,0 +1,92 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+func (m *DefaultManager) CleanupStaleWorktrees(ctx context.Context, maxAge time.Duration) (int, error) {
+ if maxAge <= 0 {
+ return 0, errors.New("worktree maximum age must be greater than zero")
+ }
+ entries, err := os.ReadDir(m.root)
+ if err != nil {
+ return 0, fmt.Errorf("list repository root for stale worktrees: %w", err)
+ }
+
+ cutoff := m.now().Add(-maxAge)
+ removed := 0
+ for _, entry := range entries {
+ if ctx.Err() != nil {
+ return removed, ctx.Err()
+ }
+ if !entry.IsDir() || !strings.HasPrefix(entry.Name(), ".analysis-") {
+ continue
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return removed, fmt.Errorf("inspect stale worktree %s: %w", entry.Name(), err)
+ }
+ if !info.ModTime().Before(cutoff) {
+ continue
+ }
+ path := filepath.Join(m.root, entry.Name())
+ if err := validateManagedChild(m.root, path); err != nil {
+ return removed, err
+ }
+ if err := os.RemoveAll(path); err != nil {
+ return removed, fmt.Errorf("remove stale worktree %s: %w", entry.Name(), err)
+ }
+ removed++
+ }
+ if removed == 0 {
+ return 0, nil
+ }
+
+ return removed, m.pruneWorktreeMetadata(ctx, entries)
+}
+
+func (m *DefaultManager) pruneWorktreeMetadata(ctx context.Context, entries []os.DirEntry) error {
+ var result error
+ for _, entry := range entries {
+ if ctx.Err() != nil {
+ return errors.Join(result, ctx.Err())
+ }
+ if !entry.IsDir() || !strings.HasSuffix(entry.Name(), ".git") {
+ continue
+ }
+ repositoryID, err := uuid.Parse(strings.TrimSuffix(entry.Name(), ".git"))
+ if err != nil {
+ continue
+ }
+ path := filepath.Join(m.root, entry.Name())
+ if err := validateManagedChild(m.root, path); err != nil {
+ result = errors.Join(result, err)
+ continue
+ }
+ unlock := m.lock(repositoryID)
+ _, err = m.run(ctx, nil, "--git-dir", path, "worktree", "prune")
+ unlock()
+ if err != nil {
+ result = errors.Join(result, fmt.Errorf("prune worktree metadata for %s: %w", entry.Name(), err))
+ }
+ }
+
+ return result
+}
+
+func validateManagedChild(root, path string) error {
+ relative, err := filepath.Rel(root, path)
+ if err != nil || relative == "." || filepath.IsAbs(relative) || strings.HasPrefix(relative, "..") || filepath.Dir(relative) != "." {
+ return fmt.Errorf("managed path %q is outside the repository root", path)
+ }
+
+ return nil
+}
diff --git a/pkg/gitrepo/janitor_test.go b/pkg/gitrepo/janitor_test.go
new file mode 100644
index 0000000..8145464
--- /dev/null
+++ b/pkg/gitrepo/janitor_test.go
@@ -0,0 +1,64 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+func TestCleanupStaleWorktreesOnlyRemovesManagedExpiredDirectories(t *testing.T) {
+ root := filepath.Join(t.TempDir(), "repositories")
+ now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
+ gitBinary := filepath.Join(t.TempDir(), "git")
+ if err := os.WriteFile(gitBinary, []byte("#!/bin/sh\nexit 0\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ manager, err := NewManager(root, WithGitBinary(gitBinary), WithClock(func() time.Time { return now }))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ stale := filepath.Join(root, ".analysis-stale")
+ fresh := filepath.Join(root, ".analysis-fresh")
+ unrelated := filepath.Join(root, "keep-me")
+ mirror := filepath.Join(root, uuid.NewString()+".git")
+ for _, path := range []string{stale, fresh, unrelated, mirror} {
+ if err := os.Mkdir(path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.Chtimes(stale, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chtimes(fresh, now.Add(-time.Hour), now.Add(-time.Hour)); err != nil {
+ t.Fatal(err)
+ }
+
+ removed, err := manager.CleanupStaleWorktrees(context.Background(), 24*time.Hour)
+ if err != nil || removed != 1 {
+ t.Fatalf("cleanup = (%d, %v), want (1, nil)", removed, err)
+ }
+ if _, err := os.Stat(stale); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("stale worktree still exists: %v", err)
+ }
+ for _, path := range []string{fresh, unrelated, mirror} {
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("cleanup removed %s: %v", path, err)
+ }
+ }
+}
+
+func TestCleanupStaleWorktreesRejectsInvalidMaximumAge(t *testing.T) {
+ manager, err := NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := manager.CleanupStaleWorktrees(context.Background(), 0); err == nil {
+ t.Fatal("zero worktree maximum age was accepted")
+ }
+}
diff --git a/pkg/gitrepo/lock_test.go b/pkg/gitrepo/lock_test.go
new file mode 100644
index 0000000..a740d19
--- /dev/null
+++ b/pkg/gitrepo/lock_test.go
@@ -0,0 +1,37 @@
+package gitrepo
+
+import (
+ "testing"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+func TestRepositoryLockSerializesSameRepository(t *testing.T) {
+ manager, err := NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ repositoryID := uuid.New()
+ unlock := manager.lock(repositoryID)
+
+ acquired := make(chan struct{})
+ go func() {
+ unlockSecond := manager.lock(repositoryID)
+ close(acquired)
+ unlockSecond()
+ }()
+
+ select {
+ case <-acquired:
+ t.Fatal("same repository lock was acquired concurrently")
+ case <-time.After(25 * time.Millisecond):
+ }
+ unlock()
+
+ select {
+ case <-acquired:
+ case <-time.After(time.Second):
+ t.Fatal("same repository lock was not released")
+ }
+}
diff --git a/pkg/gitrepo/manager.go b/pkg/gitrepo/manager.go
new file mode 100644
index 0000000..6b38a73
--- /dev/null
+++ b/pkg/gitrepo/manager.go
@@ -0,0 +1,469 @@
+package gitrepo
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+const (
+ headsRefspec = "+refs/heads/*:refs/mooncode/remotes/origin/heads/*"
+ tagsRefspec = "+refs/tags/*:refs/mooncode/remotes/origin/tags/*"
+ cleanupTimeout = 30 * time.Second
+)
+
+var (
+ ErrRefRequired = errors.New("ref is required")
+ ErrRefInvalid = errors.New("ref is not a valid branch or tag name")
+ ErrRevisionAmbiguous = errors.New("ref is ambiguous; use refs/heads/ or refs/tags/")
+ ErrRevisionNotFound = errors.New("ref was not found in the repository")
+ ErrInvalidCommitID = errors.New("git returned an invalid commit ID")
+ ErrMirrorQuota = errors.New("repository mirror exceeds configured storage limit")
+ ErrAuthentication = errors.New("repository authentication failed")
+ ErrCommandOutput = errors.New("git command output exceeded configured limit")
+)
+
+func IsPermanent(err error) bool {
+ return errors.Is(err, ErrRefRequired) || errors.Is(err, ErrRefInvalid) || errors.Is(err, ErrRevisionAmbiguous) || errors.Is(err, ErrRevisionNotFound) || errors.Is(err, ErrInvalidCommitID) || errors.Is(err, ErrMirrorQuota) || errors.Is(err, ErrAuthentication) || errors.Is(err, ErrCommandOutput) || errors.Is(err, ErrRemoteBlocked)
+}
+
+type Credential struct {
+ Username string
+ Token []byte
+}
+
+type Snapshot struct {
+ CommitSHA string
+ Author string
+ AuthoredAt time.Time
+ Title string
+ MirrorSizeBytes int64
+}
+
+type Manager interface {
+ Provision(context.Context, uuid.UUID, string, string, uuid.UUID, Credential) (Snapshot, error)
+ Sync(context.Context, uuid.UUID, string, string, uuid.UUID, Credential) (Snapshot, error)
+ Checkout(context.Context, uuid.UUID, string) (string, func() error, error)
+ ReleaseSnapshot(context.Context, uuid.UUID, uuid.UUID, string) error
+ Purge(context.Context, uuid.UUID) error
+ Path(uuid.UUID) string
+}
+
+type WorktreeJanitor interface {
+ CleanupStaleWorktrees(context.Context, time.Duration) (int, error)
+}
+
+type Option func(*DefaultManager)
+
+type DefaultManager struct {
+ root string
+ gitBin string
+ now func() time.Time
+ maxMirrorBytes int64
+ remoteValidator RemoteValidator
+ environment []string
+ maxOutputBytes int64
+ repositoryLocks [256]sync.Mutex
+}
+
+func WithGitBinary(path string) Option {
+ return func(manager *DefaultManager) { manager.gitBin = path }
+}
+
+func WithClock(now func() time.Time) Option {
+ return func(manager *DefaultManager) {
+ manager.now = now
+ }
+}
+
+func WithMaxMirrorBytes(maxBytes int64) Option {
+ return func(manager *DefaultManager) { manager.maxMirrorBytes = maxBytes }
+}
+
+func WithRemoteValidator(validator RemoteValidator) Option {
+ return func(manager *DefaultManager) { manager.remoteValidator = validator }
+}
+
+func WithMaxCommandOutputBytes(maxBytes int64) Option {
+ return func(manager *DefaultManager) { manager.maxOutputBytes = maxBytes }
+}
+
+func NewManager(root string, options ...Option) (*DefaultManager, error) {
+ absolute, err := filepath.Abs(root)
+ if err != nil {
+ return nil, fmt.Errorf("resolve repository root: %w", err)
+ }
+ manager := &DefaultManager{
+ root: absolute, gitBin: "git", now: time.Now,
+ remoteValidator: NewRemoteValidator(), environment: defaultEnvironment(), maxOutputBytes: 16 << 20,
+ }
+ for _, option := range options {
+ option(manager)
+ }
+ if manager.remoteValidator == nil {
+ return nil, errors.New("remote validator is required")
+ }
+ if manager.maxOutputBytes <= 0 {
+ return nil, errors.New("maximum Git command output must be greater than zero")
+ }
+ if err := os.MkdirAll(manager.root, 0o700); err != nil {
+ return nil, fmt.Errorf("create repository root: %w", err)
+ }
+
+ return manager, nil
+}
+
+func (m *DefaultManager) Path(id uuid.UUID) string { return filepath.Join(m.root, id.String()+".git") }
+
+func (m *DefaultManager) lock(id uuid.UUID) func() {
+ lock := &m.repositoryLocks[int(id[0])]
+ lock.Lock()
+
+ return lock.Unlock
+}
+
+func (m *DefaultManager) Provision(ctx context.Context, id uuid.UUID, remoteURL, configuredRef string, snapshotID uuid.UUID, credential Credential) (Snapshot, error) {
+ unlock := m.lock(id)
+ defer unlock()
+
+ remoteConfig, err := m.remoteValidator.Validate(ctx, remoteURL)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ if err := m.validateGitConfigSupport(ctx, remoteConfig); err != nil {
+ return Snapshot{}, err
+ }
+ repositoryPath := m.Path(id)
+ created := false
+ if _, err := os.Stat(repositoryPath); errors.Is(err, os.ErrNotExist) {
+ if _, err := m.run(ctx, nil, "init", "--bare", repositoryPath); err != nil {
+ return Snapshot{}, err
+ }
+ created = true
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "get-url", "origin"); err != nil {
+ if _, addErr := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "add", "origin", remoteURL); addErr != nil {
+ return Snapshot{}, addErr
+ }
+ } else if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "set-url", "origin", remoteURL); err != nil {
+ return Snapshot{}, err
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "config", "--replace-all", "remote.origin.fetch", headsRefspec); err != nil {
+ return Snapshot{}, fmt.Errorf("configure repository branch refspec: %w", err)
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "config", "--add", "remote.origin.fetch", tagsRefspec); err != nil {
+ return Snapshot{}, fmt.Errorf("configure repository tag refspec: %w", err)
+ }
+
+ return m.sync(ctx, repositoryPath, configuredRef, snapshotID, credential, remoteConfig, created)
+}
+
+func (m *DefaultManager) Sync(ctx context.Context, id uuid.UUID, remoteURL string, configuredRef string, snapshotID uuid.UUID, credential Credential) (Snapshot, error) {
+ unlock := m.lock(id)
+ defer unlock()
+
+ remoteConfig, err := m.remoteValidator.Validate(ctx, remoteURL)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ if err := m.validateGitConfigSupport(ctx, remoteConfig); err != nil {
+ return Snapshot{}, err
+ }
+ repositoryPath := m.Path(id)
+ if _, err := os.Stat(repositoryPath); err != nil {
+ return Snapshot{}, fmt.Errorf("managed repository is unavailable: %w", err)
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "remote", "set-url", "origin", remoteURL); err != nil {
+ return Snapshot{}, fmt.Errorf("set repository remote: %w", err)
+ }
+
+ return m.sync(ctx, repositoryPath, configuredRef, snapshotID, credential, remoteConfig, false)
+}
+
+func (m *DefaultManager) sync(ctx context.Context, repositoryPath, configuredRef string, snapshotID uuid.UUID, credential Credential, remoteConfig []GitConfig, removeOnQuota bool) (Snapshot, error) {
+ previousRefs, err := m.prepareQuota(ctx, repositoryPath, removeOnQuota)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ env := gitEnvironment(credential, remoteConfig)
+ if _, err := m.run(ctx, env, "--git-dir", repositoryPath, "fetch", "--force", "--prune", "--no-tags", "origin", headsRefspec, tagsRefspec); err != nil {
+ if _, quotaErr := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); quotaErr != nil {
+ return Snapshot{}, quotaErr
+ }
+
+ return Snapshot{}, fmt.Errorf("fetch repository: %w", err)
+ }
+ if _, err := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); err != nil {
+ return Snapshot{}, err
+ }
+ sha, err := m.resolve(ctx, repositoryPath, configuredRef)
+ if err != nil && commitPattern.MatchString(configuredRef) {
+ if _, fetchErr := m.run(ctx, env, "--git-dir", repositoryPath, "fetch", "--force", "--no-tags", "origin", configuredRef); fetchErr == nil {
+ if _, quotaErr := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); quotaErr != nil {
+ return Snapshot{}, quotaErr
+ }
+ sha, err = m.verify(ctx, repositoryPath, "FETCH_HEAD^{commit}")
+ } else if _, quotaErr := m.enforceQuota(ctx, repositoryPath, previousRefs, "", "", removeOnQuota); quotaErr != nil {
+ return Snapshot{}, quotaErr
+ }
+ }
+ if err != nil {
+ return Snapshot{}, err
+ }
+ snapshotRef := "refs/mooncode/snapshots/" + snapshotID.String()
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "update-ref", snapshotRef, sha, ""); err != nil {
+ return Snapshot{}, fmt.Errorf("pin snapshot: %w", err)
+ }
+ mirrorSize, err := m.enforceQuota(ctx, repositoryPath, previousRefs, snapshotRef, sha, removeOnQuota)
+ if err != nil {
+ return Snapshot{}, err
+ }
+ metadata, err := m.run(ctx, nil, "--git-dir", repositoryPath, "show", "-s", "--format=%an%x00%aI%x00%s", sha)
+ if err != nil {
+ cleanupContext, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
+ _, _ = m.run(cleanupContext, nil, "--git-dir", repositoryPath, "update-ref", "-d", snapshotRef, sha)
+ cancel()
+ return Snapshot{}, fmt.Errorf("read commit metadata: %w", err)
+ }
+ parts := strings.SplitN(strings.TrimSpace(metadata), "\x00", 3)
+ result := Snapshot{CommitSHA: sha}
+ if len(parts) > 0 {
+ result.Author = parts[0]
+ }
+ if len(parts) > 1 {
+ result.AuthoredAt, _ = time.Parse(time.RFC3339, parts[1])
+ }
+ if len(parts) > 2 {
+ result.Title = parts[2]
+ }
+ result.MirrorSizeBytes = mirrorSize
+
+ return result, nil
+}
+
+func (m *DefaultManager) resolve(ctx context.Context, repositoryPath, configuredRef string) (string, error) {
+ value := strings.TrimSpace(configuredRef)
+ if value == "" {
+ return "", ErrRefRequired
+ }
+ if strings.HasPrefix(value, "refs/heads/") {
+ valid, err := m.validRef(ctx, value)
+ if err != nil {
+ return "", err
+ }
+ if !valid {
+ return "", ErrRefInvalid
+ }
+
+ return m.verifyFetchedRef(ctx, repositoryPath, "refs/mooncode/remotes/origin/heads/"+strings.TrimPrefix(value, "refs/heads/")+"^{commit}")
+ }
+ if strings.HasPrefix(value, "refs/tags/") {
+ valid, err := m.validRef(ctx, value)
+ if err != nil {
+ return "", err
+ }
+ if !valid {
+ return "", ErrRefInvalid
+ }
+
+ return m.verifyFetchedRef(ctx, repositoryPath, "refs/mooncode/remotes/origin/tags/"+strings.TrimPrefix(value, "refs/tags/")+"^{commit}")
+ }
+ if strings.HasPrefix(value, "refs/") {
+ return "", ErrRefInvalid
+ }
+ if !commitPattern.MatchString(value) {
+ valid, err := m.validRef(ctx, "refs/heads/"+value)
+ if err != nil {
+ return "", err
+ }
+ if !valid {
+ return "", ErrRefInvalid
+ }
+ }
+ branch, branchErr := m.verify(ctx, repositoryPath, "refs/mooncode/remotes/origin/heads/"+value+"^{commit}")
+ tag, tagErr := m.verify(ctx, repositoryPath, "refs/mooncode/remotes/origin/tags/"+value+"^{commit}")
+ if branchErr == nil && tagErr == nil && branch != tag {
+ return "", ErrRevisionAmbiguous
+ }
+ if branchErr == nil {
+ return branch, nil
+ }
+ if tagErr == nil {
+ return tag, nil
+ }
+ if commitPattern.MatchString(value) {
+ return m.verifyFetchedRef(ctx, repositoryPath, value+"^{commit}")
+ }
+
+ return "", ErrRevisionNotFound
+}
+
+func (m *DefaultManager) validRef(ctx context.Context, value string) (bool, error) {
+ _, err := m.run(ctx, nil, "check-ref-format", value)
+ if ctx.Err() != nil {
+ return false, ctx.Err()
+ }
+
+ return err == nil, nil
+}
+
+func (m *DefaultManager) verifyFetchedRef(ctx context.Context, repositoryPath, revision string) (string, error) {
+ sha, err := m.verify(ctx, repositoryPath, revision)
+ if err != nil {
+ return "", ErrRevisionNotFound
+ }
+
+ return sha, nil
+}
+
+func (m *DefaultManager) verify(ctx context.Context, repositoryPath, revision string) (string, error) {
+ output, err := m.run(ctx, nil, "--git-dir", repositoryPath, "rev-parse", "--verify", revision)
+ if err != nil {
+ return "", err
+ }
+ sha := strings.TrimSpace(output)
+ if !commitPattern.MatchString(sha) {
+ return "", ErrInvalidCommitID
+ }
+
+ return sha, nil
+}
+
+func (m *DefaultManager) Checkout(ctx context.Context, id uuid.UUID, commitSHA string) (string, func() error, error) {
+ unlock := m.lock(id)
+ defer unlock()
+
+ repositoryPath := m.Path(id)
+ directory, err := os.MkdirTemp(m.root, ".analysis-")
+ if err != nil {
+ return "", nil, fmt.Errorf("create analysis worktree: %w", err)
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "worktree", "add", "--detach", directory, commitSHA); err != nil {
+ _ = os.RemoveAll(directory)
+ return "", nil, fmt.Errorf("create detached worktree: %w", err)
+ }
+ cleanup := func() error {
+ unlockCleanup := m.lock(id)
+ defer unlockCleanup()
+
+ cleanupContext, cancel := context.WithTimeout(context.Background(), cleanupTimeout)
+ defer cancel()
+
+ _, removeErr := m.run(cleanupContext, nil, "--git-dir", repositoryPath, "worktree", "remove", "--force", directory)
+ if removeErr != nil {
+ _ = os.RemoveAll(directory)
+ }
+ return removeErr
+ }
+
+ return directory, cleanup, nil
+}
+
+func (m *DefaultManager) Purge(ctx context.Context, id uuid.UUID) error {
+ unlock := m.lock(id)
+ defer unlock()
+
+ path := m.Path(id)
+ if err := validateManagedChild(m.root, path); err != nil {
+ return err
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", path, "worktree", "prune"); err != nil && !errors.Is(err, os.ErrNotExist) {
+ _ = err
+ }
+ if err := os.RemoveAll(path); err != nil {
+ return fmt.Errorf("purge managed repository: %w", err)
+ }
+
+ return nil
+}
+
+func (m *DefaultManager) run(ctx context.Context, extraEnv []string, args ...string) (string, error) {
+ return m.runInput(ctx, "", extraEnv, args...)
+}
+
+func (m *DefaultManager) runInput(ctx context.Context, input string, extraEnv []string, args ...string) (string, error) {
+ command := exec.CommandContext(ctx, m.gitBin, args...)
+ command.Env = append([]string(nil), m.environment...)
+ command.Env = append(command.Env, extraEnv...)
+ command.Env = append(command.Env,
+ "GIT_TERMINAL_PROMPT=0",
+ "GIT_CONFIG_NOSYSTEM=1",
+ "GIT_CONFIG_GLOBAL=/dev/null",
+ )
+ if input != "" {
+ command.Stdin = strings.NewReader(input)
+ }
+ output := newOutputCapture(m.maxOutputBytes)
+ command.Stdout = output
+ command.Stderr = output
+ err := command.Run()
+ if ctx.Err() != nil {
+ return "", fmt.Errorf("git command: %w", ctx.Err())
+ }
+ if output.Exceeded() {
+ return "", ErrCommandOutput
+ }
+ if err != nil {
+ diagnostic := safeGitError(redactGitDiagnostic(output.String(), extraEnv))
+ if authenticationFailure(diagnostic) {
+ return "", fmt.Errorf("%w: %s", ErrAuthentication, diagnostic)
+ }
+
+ return "", fmt.Errorf("git command failed: %s", diagnostic)
+ }
+
+ return output.String(), nil
+}
+
+func credentialEnvironment(credential Credential) []string {
+ return gitEnvironment(credential, nil)
+}
+
+func gitEnvironment(credential Credential, configs []GitConfig) []string {
+ entries := append([]GitConfig(nil), configs...)
+ if len(credential.Token) == 0 {
+ return gitConfigEnvironment(entries)
+ }
+ username := credential.Username
+ if username == "" {
+ username = "oauth2"
+ }
+ raw := make([]byte, 0, len(username)+1+len(credential.Token))
+ raw = append(raw, username...)
+ raw = append(raw, ':')
+ raw = append(raw, credential.Token...)
+ value := base64.StdEncoding.EncodeToString(raw)
+ clear(raw)
+ entries = append(entries, GitConfig{Key: "http.extraHeader", Value: "Authorization: Basic " + value})
+
+ return gitConfigEnvironment(entries)
+}
+
+func gitConfigEnvironment(configs []GitConfig) []string {
+ if len(configs) == 0 {
+ return nil
+ }
+ environment := make([]string, 0, 1+2*len(configs))
+ environment = append(environment, "GIT_CONFIG_COUNT="+strconv.Itoa(len(configs)))
+ for index, config := range configs {
+ environment = append(environment,
+ "GIT_CONFIG_KEY_"+strconv.Itoa(index)+"="+config.Key,
+ "GIT_CONFIG_VALUE_"+strconv.Itoa(index)+"="+config.Value,
+ )
+ }
+
+ return environment
+}
+
+var commitPattern = regexp.MustCompile(`^[0-9a-fA-F]{40,64}$`)
diff --git a/pkg/gitrepo/manager_test.go b/pkg/gitrepo/manager_test.go
new file mode 100644
index 0000000..dfa6521
--- /dev/null
+++ b/pkg/gitrepo/manager_test.go
@@ -0,0 +1,184 @@
+package gitrepo
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestCredentialEnvironmentUsesEphemeralHeader(t *testing.T) {
+ token := []byte("github_pat_secret")
+ environment := credentialEnvironment(Credential{Username: "octocat", Token: token})
+ if len(environment) != 3 || environment[0] != "GIT_CONFIG_COUNT=1" || environment[1] != "GIT_CONFIG_KEY_0=http.extraHeader" {
+ t.Fatalf("unexpected credential environment: %v", environment)
+ }
+ encoded := strings.TrimPrefix(environment[2], "GIT_CONFIG_VALUE_0=Authorization: Basic ")
+ decoded, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer clear(decoded)
+ if string(decoded) != "octocat:github_pat_secret" {
+ t.Fatal("credential header did not contain the requested identity")
+ }
+ if string(token) != "github_pat_secret" {
+ t.Fatal("credential environment changed the caller-owned token")
+ }
+}
+
+func TestGitProcessDoesNotInheritWorkerSecrets(t *testing.T) {
+ t.Setenv("MOONCODE_SECRETS_KEY", "must-not-reach-git")
+ t.Setenv("MOONCODE_DATABASE_URL", "postgres://secret")
+ t.Setenv("MOONCODE_HATCHET_TOKEN", "hatchet-secret")
+ binary := filepath.Join(t.TempDir(), "git")
+ script := `#!/bin/sh
+if env | grep -E '^(MOONCODE_SECRETS_KEY|MOONCODE_DATABASE_URL|MOONCODE_HATCHET_TOKEN)=' >/dev/null; then
+ echo 'worker secret reached git' >&2
+ exit 1
+fi
+printf 'safe'
+`
+ if err := os.WriteFile(binary, []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ manager, err := NewManager(t.TempDir(), WithGitBinary(binary))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ output, err := manager.run(context.Background(), nil, "--version")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if output != "safe" {
+ t.Fatalf("git output = %q, want safe", output)
+ }
+}
+
+func TestGitProcessKeepsConfiguredNetworkProxy(t *testing.T) {
+ t.Setenv("HTTPS_PROXY", "http://proxy.example:8080")
+ binary := filepath.Join(t.TempDir(), "git")
+ if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%s' \"$HTTPS_PROXY\"\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ manager, err := NewManager(t.TempDir(), WithGitBinary(binary))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ output, err := manager.run(context.Background(), nil, "--version")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if output != "http://proxy.example:8080" {
+ t.Fatalf("git proxy = %q", output)
+ }
+}
+
+func TestManagerRejectsGitWithoutPinnedResolutionSupport(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "git")
+ script := `#!/bin/sh
+if [ "$1" = help ] && [ "$2" = --config ]; then
+ printf '%s\n' 'http.followRedirects'
+ exit 0
+fi
+exit 99
+`
+ if err := os.WriteFile(binary, []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ validator := RemoteValidatorFunc(func(context.Context, string) ([]GitConfig, error) {
+ return []GitConfig{{Key: "http.curloptResolve", Value: "github.com:443:1.1.1.1"}}, nil
+ })
+ manager, err := NewManager(t.TempDir(), WithGitBinary(binary), WithRemoteValidator(validator))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = manager.Provision(context.Background(), uuid.New(), "https://github.com/example/repo.git", "main", uuid.New(), Credential{})
+ if !errors.Is(err, ErrRemoteBlocked) {
+ t.Fatalf("Provision() error = %v, want blocked unsupported Git", err)
+ }
+}
+
+func TestGitConfigAvailableRequiresExactName(t *testing.T) {
+ if !gitConfigAvailable("http.followRedirects\nhttp.curloptResolve\n", "http.curloptResolve") {
+ t.Fatal("expected Git config capability")
+ }
+ if gitConfigAvailable("http.curloptResolveExtra\n", "http.curloptResolve") {
+ t.Fatal("accepted a partial Git config name")
+ }
+}
+
+func TestGitFailureRedactsCredentialEnvironmentAndDecodedToken(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "git")
+ script := `#!/bin/sh
+printf '%s\n' "$GIT_CONFIG_VALUE_0" >&2
+printf '%s\n' 'octocat:github_pat_secret' >&2
+exit 1
+`
+ if err := os.WriteFile(binary, []byte(script), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ manager, err := NewManager(t.TempDir(), WithGitBinary(binary))
+ if err != nil {
+ t.Fatal(err)
+ }
+ environment := credentialEnvironment(Credential{Username: "octocat", Token: []byte("github_pat_secret")})
+
+ _, err = manager.run(context.Background(), environment, "fetch")
+ if err == nil {
+ t.Fatal("expected Git failure")
+ }
+ message := err.Error()
+ if strings.Contains(message, "github_pat_secret") || strings.Contains(message, "octocat:") || strings.Contains(message, "Authorization: Basic") {
+ t.Fatalf("Git diagnostic leaked a credential: %q", message)
+ }
+ if !strings.Contains(message, "[REDACTED]") {
+ t.Fatalf("Git diagnostic did not show redaction: %q", message)
+ }
+}
+
+func TestGitCommandOutputIsBounded(t *testing.T) {
+ binary := filepath.Join(t.TempDir(), "git")
+ if err := os.WriteFile(binary, []byte("#!/bin/sh\nprintf '%040d' 0\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ manager, err := NewManager(t.TempDir(), WithGitBinary(binary), WithMaxCommandOutputBytes(32))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ _, err = manager.run(context.Background(), nil, "--version")
+ if !errors.Is(err, ErrCommandOutput) || !IsPermanent(err) {
+ t.Fatalf("run() error = %v, want permanent bounded-output error", err)
+ }
+}
+
+func TestGitEnvironmentCombinesRemotePolicyAndCredential(t *testing.T) {
+ environment := gitEnvironment(
+ Credential{Username: "octocat", Token: []byte("github_pat_secret")},
+ []GitConfig{
+ {Key: "http.followRedirects", Value: "false"},
+ {Key: "http.curloptResolve", Value: "github.com:443:8.8.8.8"},
+ },
+ )
+ if len(environment) != 7 || environment[0] != "GIT_CONFIG_COUNT=3" {
+ t.Fatalf("unexpected Git config environment: %v", environment)
+ }
+ if environment[1] != "GIT_CONFIG_KEY_0=http.followRedirects" || environment[2] != "GIT_CONFIG_VALUE_0=false" {
+ t.Fatalf("remote redirect policy was not preserved: %v", environment)
+ }
+ if environment[3] != "GIT_CONFIG_KEY_1=http.curloptResolve" || environment[4] != "GIT_CONFIG_VALUE_1=github.com:443:8.8.8.8" {
+ t.Fatalf("validated address was not pinned: %v", environment)
+ }
+ if environment[5] != "GIT_CONFIG_KEY_2=http.extraHeader" || !strings.HasPrefix(environment[6], "GIT_CONFIG_VALUE_2=Authorization: Basic ") {
+ t.Fatalf("credential header was not appended: %v", environment)
+ }
+}
diff --git a/pkg/gitrepo/quota.go b/pkg/gitrepo/quota.go
new file mode 100644
index 0000000..3b968c1
--- /dev/null
+++ b/pkg/gitrepo/quota.go
@@ -0,0 +1,176 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+var quotaTrackedRefPrefixes = []string{
+ "refs/mooncode/remotes/origin/",
+ "refs/remotes/origin/",
+}
+
+type MirrorQuotaError struct {
+ LimitBytes int64
+ ActualBytes int64
+}
+
+func (e *MirrorQuotaError) Error() string {
+ return fmt.Sprintf("%s: limit=%d actual=%d", ErrMirrorQuota, e.LimitBytes, e.ActualBytes)
+}
+
+func (e *MirrorQuotaError) Is(target error) bool { return target == ErrMirrorQuota }
+
+func (m *DefaultManager) prepareQuota(ctx context.Context, repositoryPath string, removeOnQuota bool) (map[string]string, error) {
+ if m.maxMirrorBytes <= 0 {
+ return nil, nil
+ }
+
+ size, err := directorySize(repositoryPath)
+ if err != nil {
+ return nil, fmt.Errorf("measure repository mirror: %w", err)
+ }
+ if size > m.maxMirrorBytes {
+ quotaErr := &MirrorQuotaError{LimitBytes: m.maxMirrorBytes, ActualBytes: size}
+ if removeOnQuota {
+ if err := m.removeMirror(repositoryPath); err != nil {
+ return nil, errors.Join(quotaErr, err)
+ }
+ }
+
+ return nil, quotaErr
+ }
+
+ return m.quotaTrackedRefs(ctx, repositoryPath)
+}
+
+func (m *DefaultManager) enforceQuota(ctx context.Context, repositoryPath string, previousRefs map[string]string, snapshotRef, snapshotSHA string, removeOnQuota bool) (int64, error) {
+ size, err := directorySize(repositoryPath)
+ if err != nil {
+ return 0, fmt.Errorf("measure repository mirror: %w", err)
+ }
+ if m.maxMirrorBytes <= 0 || size <= m.maxMirrorBytes {
+ return size, nil
+ }
+
+ quotaErr := &MirrorQuotaError{LimitBytes: m.maxMirrorBytes, ActualBytes: size}
+ if removeOnQuota {
+ if err := m.removeMirror(repositoryPath); err != nil {
+ return 0, errors.Join(quotaErr, err)
+ }
+
+ return 0, quotaErr
+ }
+ if err := m.rollbackQuota(ctx, repositoryPath, previousRefs, snapshotRef, snapshotSHA); err != nil {
+ return 0, errors.Join(quotaErr, fmt.Errorf("rollback over-quota mirror: %w", err))
+ }
+ cleanedSize, err := directorySize(repositoryPath)
+ if err != nil {
+ return 0, errors.Join(quotaErr, fmt.Errorf("measure rolled back repository mirror: %w", err))
+ }
+ if cleanedSize > m.maxMirrorBytes {
+ return 0, errors.Join(quotaErr, fmt.Errorf("rolled back repository mirror remains over quota: size=%d", cleanedSize))
+ }
+
+ return 0, quotaErr
+}
+
+func (m *DefaultManager) rollbackQuota(ctx context.Context, repositoryPath string, previousRefs map[string]string, snapshotRef, snapshotSHA string) error {
+ currentRefs, err := m.quotaTrackedRefs(ctx, repositoryPath)
+ if err != nil {
+ return err
+ }
+
+ commands := []string{"start"}
+ if snapshotRef != "" {
+ commands = append(commands, "delete "+snapshotRef+" "+snapshotSHA)
+ }
+ refNames := make([]string, 0, len(currentRefs)+len(previousRefs))
+ for ref := range currentRefs {
+ refNames = append(refNames, ref)
+ }
+ for ref := range previousRefs {
+ if _, ok := currentRefs[ref]; !ok {
+ refNames = append(refNames, ref)
+ }
+ }
+ sort.Strings(refNames)
+ for _, ref := range refNames {
+ currentSHA, current := currentRefs[ref]
+ previousSHA, previous := previousRefs[ref]
+ switch {
+ case current && previous && currentSHA != previousSHA:
+ commands = append(commands, "update "+ref+" "+previousSHA+" "+currentSHA)
+ case current && !previous:
+ commands = append(commands, "delete "+ref+" "+currentSHA)
+ case !current && previous:
+ commands = append(commands, "create "+ref+" "+previousSHA)
+ }
+ }
+ commands = append(commands, "prepare", "commit", "")
+ if _, err := m.runInput(ctx, strings.Join(commands, "\n"), nil, "--git-dir", repositoryPath, "update-ref", "--stdin"); err != nil {
+ return err
+ }
+ if err := os.Remove(filepath.Join(repositoryPath, "FETCH_HEAD")); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove fetch head: %w", err)
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "reflog", "expire", "--expire=now", "--all"); err != nil {
+ return err
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "repack", "-Ad"); err != nil {
+ return err
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "prune", "--expire=now"); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (m *DefaultManager) quotaTrackedRefs(ctx context.Context, repositoryPath string) (map[string]string, error) {
+ args := []string{"--git-dir", repositoryPath, "for-each-ref", "--format=%(refname)%09%(objectname)"}
+ args = append(args, quotaTrackedRefPrefixes...)
+ output, err := m.run(ctx, nil, args...)
+ if err != nil {
+ return nil, err
+ }
+ refs := make(map[string]string)
+ for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
+ if line == "" {
+ continue
+ }
+ parts := strings.SplitN(line, "\t", 2)
+ if len(parts) != 2 || !trackedQuotaRef(parts[0]) || !commitPattern.MatchString(parts[1]) {
+ return nil, fmt.Errorf("git returned an invalid managed ref")
+ }
+ refs[parts[0]] = parts[1]
+ }
+
+ return refs, nil
+}
+
+func trackedQuotaRef(ref string) bool {
+ for _, prefix := range quotaTrackedRefPrefixes {
+ if strings.HasPrefix(ref, prefix) {
+ return true
+ }
+ }
+
+ return false
+}
+
+func (m *DefaultManager) removeMirror(repositoryPath string) error {
+ if err := validateManagedChild(m.root, repositoryPath); err != nil {
+ return err
+ }
+ if err := os.RemoveAll(repositoryPath); err != nil {
+ return fmt.Errorf("remove over-quota repository mirror: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/gitrepo/quota_test.go b/pkg/gitrepo/quota_test.go
new file mode 100644
index 0000000..9254653
--- /dev/null
+++ b/pkg/gitrepo/quota_test.go
@@ -0,0 +1,102 @@
+package gitrepo
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestProvisionRemovesNewMirrorWhenQuotaIsExceeded(t *testing.T) {
+ manager, err := NewManager(t.TempDir(), WithMaxMirrorBytes(1), allowTestRemotes())
+ if err != nil {
+ t.Fatal(err)
+ }
+ repositoryID := uuid.New()
+
+ _, err = manager.Provision(context.Background(), repositoryID, filepath.Join(t.TempDir(), "remote.git"), "main", uuid.New(), Credential{})
+ if !errors.Is(err, ErrMirrorQuota) {
+ t.Fatalf("Provision() error = %v, want mirror quota error", err)
+ }
+ if _, statErr := os.Stat(manager.Path(repositoryID)); !errors.Is(statErr, os.ErrNotExist) {
+ t.Fatalf("over-quota provision left a mirror behind: %v", statErr)
+ }
+}
+
+func TestSyncRollsBackFetchedObjectsAndRefsWhenQuotaIsExceeded(t *testing.T) {
+ ctx := context.Background()
+ root := t.TempDir()
+ source := filepath.Join(root, "source")
+ remote := filepath.Join(root, "remote.git")
+ managed := filepath.Join(root, "managed")
+ mustGit(t, root, "init", "-b", "main", source)
+ mustGit(t, source, "config", "user.name", "MoonCode Test")
+ mustGit(t, source, "config", "user.email", "mooncode@example.com")
+ if err := os.WriteFile(filepath.Join(source, "content.txt"), []byte("first\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ mustGit(t, source, "add", "content.txt")
+ mustGit(t, source, "commit", "-m", "first")
+ firstSHA := strings.TrimSpace(mustGit(t, source, "rev-parse", "HEAD"))
+ mustGit(t, root, "clone", "--bare", source, remote)
+
+ repositoryID, firstSnapshotID := uuid.New(), uuid.New()
+ manager, err := NewManager(managed, allowTestRemotes())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := manager.Provision(ctx, repositoryID, remote, "main", firstSnapshotID, Credential{}); err != nil {
+ t.Fatal(err)
+ }
+ initialSize, err := directorySize(manager.Path(repositoryID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ limit := initialSize + 256*1024
+
+ large := make([]byte, 2*1024*1024)
+ if _, err := rand.Read(large); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(source, "large.bin"), large, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ clear(large)
+ mustGit(t, source, "add", "large.bin")
+ mustGit(t, source, "commit", "-m", "large update")
+ mustGit(t, source, "push", remote, "main")
+
+ limited, err := NewManager(managed, WithMaxMirrorBytes(limit), allowTestRemotes())
+ if err != nil {
+ t.Fatal(err)
+ }
+ secondSnapshotID := uuid.New()
+ _, err = limited.Sync(ctx, repositoryID, remote, "main", secondSnapshotID, Credential{})
+ if !errors.Is(err, ErrMirrorQuota) {
+ t.Fatalf("Sync() error = %v, want mirror quota error", err)
+ }
+
+ remoteSHA := strings.TrimSpace(mustGit(t, root, "--git-dir", limited.Path(repositoryID), "rev-parse", "refs/mooncode/remotes/origin/heads/main"))
+ if remoteSHA != firstSHA {
+ t.Fatalf("over-quota sync moved managed branch to %s, want %s", remoteSHA, firstSHA)
+ }
+ pinnedSHA := strings.TrimSpace(mustGit(t, root, "--git-dir", limited.Path(repositoryID), "rev-parse", "refs/mooncode/snapshots/"+firstSnapshotID.String()))
+ if pinnedSHA != firstSHA {
+ t.Fatalf("over-quota sync changed pinned snapshot to %s, want %s", pinnedSHA, firstSHA)
+ }
+ if output := mustGit(t, root, "--git-dir", limited.Path(repositoryID), "for-each-ref", "--format=%(refname)", "refs/mooncode/snapshots/"+secondSnapshotID.String()); strings.TrimSpace(output) != "" {
+ t.Fatalf("over-quota sync retained a new snapshot ref: %s", output)
+ }
+ finalSize, err := directorySize(limited.Path(repositoryID))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if finalSize > limit {
+ t.Fatalf("rolled back mirror size = %d, limit = %d", finalSize, limit)
+ }
+}
diff --git a/pkg/gitrepo/remote.go b/pkg/gitrepo/remote.go
new file mode 100644
index 0000000..1d6777b
--- /dev/null
+++ b/pkg/gitrepo/remote.go
@@ -0,0 +1,80 @@
+package gitrepo
+
+import (
+ "errors"
+ "net/url"
+ "path"
+ "strings"
+)
+
+type Remote struct {
+ ProviderType string
+ URL string
+ Normalized string
+ Name string
+}
+
+func NormalizePublicRemote(raw string) (Remote, error) {
+ parsed, cleanPath, err := parseRemote(raw)
+ if err != nil {
+ return Remote{}, err
+ }
+
+ providerType := ""
+ switch strings.ToLower(parsed.Hostname()) {
+ case "github.com":
+ providerType = "github"
+ case "gitlab.com":
+ providerType = "gitlab"
+ default:
+ return Remote{}, errors.New("public repository host is not supported")
+ }
+
+ return buildRemote(providerType, parsed, cleanPath), nil
+}
+
+func NormalizeRemote(providerType, providerBaseURL, raw string) (Remote, error) {
+ parsed, cleanPath, err := parseRemote(raw)
+ if err != nil {
+ return Remote{}, err
+ }
+ base, err := url.Parse(strings.TrimRight(providerBaseURL, "/"))
+ if err != nil || base.Scheme != "https" || !strings.EqualFold(base.Hostname(), parsed.Hostname()) {
+ return Remote{}, errors.New("repository URL does not belong to the selected provider connection")
+ }
+ if parsed.Port() != base.Port() {
+ return Remote{}, errors.New("repository URL port does not match provider connection")
+ }
+ basePath := strings.TrimSuffix(path.Clean("/"+base.Path), "/")
+ if basePath != "" && basePath != "/" && cleanPath != basePath && !strings.HasPrefix(cleanPath, basePath+"/") {
+ return Remote{}, errors.New("repository URL is outside provider base path")
+ }
+ return buildRemote(providerType, parsed, cleanPath), nil
+}
+
+func parseRemote(raw string) (*url.URL, string, error) {
+ parsed, err := url.Parse(strings.TrimSpace(raw))
+ if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return nil, "", errors.New("repository URL must be a credential-free HTTPS URL")
+ }
+ cleanPath := strings.TrimSuffix(path.Clean("/"+parsed.EscapedPath()), ".git")
+ cleanPath, err = url.PathUnescape(cleanPath)
+ if err != nil || cleanPath == "/" || strings.Contains(cleanPath, "..") {
+ return nil, "", errors.New("repository URL path is invalid")
+ }
+ parts := strings.Split(strings.Trim(cleanPath, "/"), "/")
+ if len(parts) < 2 {
+ return nil, "", errors.New("repository URL must include owner and repository")
+ }
+
+ return parsed, cleanPath, nil
+}
+
+func buildRemote(providerType string, parsed *url.URL, cleanPath string) Remote {
+ parts := strings.Split(strings.Trim(cleanPath, "/"), "/")
+ name := parts[len(parts)-1]
+ canonical := parsed.Scheme + "://" + strings.ToLower(parsed.Host) + cleanPath + ".git"
+ normalized := strings.ToLower(parsed.Host + cleanPath)
+
+ return Remote{ProviderType: providerType, URL: canonical, Normalized: normalized, Name: name}
+}
diff --git a/pkg/gitrepo/remote_access.go b/pkg/gitrepo/remote_access.go
new file mode 100644
index 0000000..852e4c3
--- /dev/null
+++ b/pkg/gitrepo/remote_access.go
@@ -0,0 +1,144 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "net/url"
+ "strconv"
+ "strings"
+)
+
+var ErrRemoteBlocked = errors.New("repository remote is blocked by network policy")
+
+type GitConfig struct {
+ Key string
+ Value string
+}
+
+type RemoteValidator interface {
+ Validate(context.Context, string) ([]GitConfig, error)
+}
+
+type RemoteValidatorFunc func(context.Context, string) ([]GitConfig, error)
+
+func (f RemoteValidatorFunc) Validate(ctx context.Context, remoteURL string) ([]GitConfig, error) {
+ return f(ctx, remoteURL)
+}
+
+type DNSResolver interface {
+ LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
+}
+
+type DefaultRemoteValidator struct {
+ resolver DNSResolver
+}
+
+type RemoteValidatorOption func(*DefaultRemoteValidator)
+
+func WithDNSResolver(resolver DNSResolver) RemoteValidatorOption {
+ return func(validator *DefaultRemoteValidator) { validator.resolver = resolver }
+}
+
+func NewRemoteValidator(options ...RemoteValidatorOption) *DefaultRemoteValidator {
+ validator := &DefaultRemoteValidator{resolver: net.DefaultResolver}
+ for _, option := range options {
+ option(validator)
+ }
+
+ return validator
+}
+
+func (v *DefaultRemoteValidator) Validate(ctx context.Context, remoteURL string) ([]GitConfig, error) {
+ parsed, err := url.Parse(strings.TrimSpace(remoteURL))
+ if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return nil, fmt.Errorf("%w: remote must be a credential-free HTTPS URL", ErrRemoteBlocked)
+ }
+ host := strings.TrimSuffix(strings.ToLower(parsed.Hostname()), ".")
+ if host == "" {
+ return nil, fmt.Errorf("%w: remote host is required", ErrRemoteBlocked)
+ }
+ port := parsed.Port()
+ if port == "" {
+ port = "443"
+ }
+ if _, err := strconv.ParseUint(port, 10, 16); err != nil {
+ return nil, fmt.Errorf("%w: remote port is invalid", ErrRemoteBlocked)
+ }
+
+ addresses, err := v.resolver.LookupIPAddr(ctx, host)
+ if err != nil {
+ return nil, fmt.Errorf("resolve repository remote: %w", err)
+ }
+ if len(addresses) == 0 {
+ return nil, fmt.Errorf("resolve repository remote: no addresses returned")
+ }
+ configs := []GitConfig{{Key: "http.followRedirects", Value: "false"}}
+ seen := make(map[string]struct{}, len(addresses))
+ for _, address := range addresses {
+ ip := address.IP
+ if !publicRemoteIP(ip) {
+ return nil, fmt.Errorf("%w: remote resolved to a non-public address", ErrRemoteBlocked)
+ }
+ value := ip.String()
+ if _, ok := seen[value]; ok {
+ continue
+ }
+ seen[value] = struct{}{}
+ if ip.To4() == nil {
+ value = "[" + value + "]"
+ }
+ configs = append(configs, GitConfig{Key: "http.curloptResolve", Value: host + ":" + port + ":" + value})
+ }
+
+ return configs, nil
+}
+
+func publicRemoteIP(ip net.IP) bool {
+ if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() {
+ return false
+ }
+ for _, network := range blockedRemoteNetworks {
+ if network.Contains(ip) {
+ return false
+ }
+ }
+
+ return true
+}
+
+var blockedRemoteNetworks = parseRemoteNetworks([]string{
+ "0.0.0.0/8",
+ "100.64.0.0/10",
+ "127.0.0.0/8",
+ "169.254.0.0/16",
+ "192.0.0.0/24",
+ "192.0.2.0/24",
+ "198.18.0.0/15",
+ "198.51.100.0/24",
+ "203.0.113.0/24",
+ "224.0.0.0/4",
+ "240.0.0.0/4",
+ "::/128",
+ "::1/128",
+ "64:ff9b:1::/48",
+ "100::/64",
+ "2001:db8::/32",
+ "fc00::/7",
+ "fe80::/10",
+ "ff00::/8",
+})
+
+func parseRemoteNetworks(values []string) []*net.IPNet {
+ networks := make([]*net.IPNet, 0, len(values))
+ for _, value := range values {
+ _, network, err := net.ParseCIDR(value)
+ if err != nil {
+ panic("invalid built-in remote network: " + value)
+ }
+ networks = append(networks, network)
+ }
+
+ return networks
+}
diff --git a/pkg/gitrepo/remote_access_test.go b/pkg/gitrepo/remote_access_test.go
new file mode 100644
index 0000000..8d6453c
--- /dev/null
+++ b/pkg/gitrepo/remote_access_test.go
@@ -0,0 +1,86 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "net"
+ "testing"
+)
+
+type staticResolver struct {
+ addresses []net.IPAddr
+ err error
+}
+
+func (r staticResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) {
+ return r.addresses, r.err
+}
+
+func TestRemoteValidatorPinsValidatedPublicAddresses(t *testing.T) {
+ validator := NewRemoteValidator(WithDNSResolver(staticResolver{addresses: []net.IPAddr{
+ {IP: net.ParseIP("8.8.8.8")},
+ {IP: net.ParseIP("2001:4860:4860::8888")},
+ {IP: net.ParseIP("8.8.8.8")},
+ }}))
+
+ configs, err := validator.Validate(context.Background(), "https://GitHub.com/owner/repo.git")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(configs) != 3 || configs[0] != (GitConfig{Key: "http.followRedirects", Value: "false"}) || configs[1].Key != "http.curloptResolve" || configs[1].Value != "github.com:443:8.8.8.8" || configs[2].Value != "github.com:443:[2001:4860:4860::8888]" {
+ t.Fatalf("unexpected pinned remote config: %#v", configs)
+ }
+}
+
+func TestRemoteValidatorRejectsNonPublicAddresses(t *testing.T) {
+ addresses := []string{
+ "0.0.0.0",
+ "10.0.0.1",
+ "100.64.0.1",
+ "127.0.0.1",
+ "169.254.169.254",
+ "192.0.2.1",
+ "198.18.0.1",
+ "224.0.0.1",
+ "::1",
+ "2001:db8::1",
+ "fc00::1",
+ "fe80::1",
+ }
+ for _, value := range addresses {
+ t.Run(value, func(t *testing.T) {
+ validator := NewRemoteValidator(WithDNSResolver(staticResolver{addresses: []net.IPAddr{{IP: net.ParseIP(value)}}}))
+
+ _, err := validator.Validate(context.Background(), "https://github.com/owner/repo.git")
+ if !errors.Is(err, ErrRemoteBlocked) {
+ t.Fatalf("Validate() error = %v, want blocked remote", err)
+ }
+ })
+ }
+}
+
+func TestRemoteValidatorRejectsUnsafeURLsBeforeDNS(t *testing.T) {
+ validator := NewRemoteValidator(WithDNSResolver(staticResolver{addresses: []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}}))
+ values := []string{
+ "http://github.com/owner/repo.git",
+ "file:///tmp/repo.git",
+ "https://token@github.com/owner/repo.git",
+ "https://github.com/owner/repo.git?token=secret",
+ "https://github.com/owner/repo.git#fragment",
+ }
+ for _, value := range values {
+ if _, err := validator.Validate(context.Background(), value); !errors.Is(err, ErrRemoteBlocked) {
+ t.Fatalf("Validate(%q) error = %v, want blocked remote", value, err)
+ }
+ }
+}
+
+func TestRemoteValidatorPreservesResolutionFailuresAsRetryable(t *testing.T) {
+ want := errors.New("temporary DNS failure")
+ validator := NewRemoteValidator(WithDNSResolver(staticResolver{err: want}))
+
+ _, err := validator.Validate(context.Background(), "https://github.com/owner/repo.git")
+ if !errors.Is(err, want) || errors.Is(err, ErrRemoteBlocked) {
+ t.Fatalf("Validate() error = %v, want retryable resolution failure", err)
+ }
+}
diff --git a/pkg/gitrepo/remote_test.go b/pkg/gitrepo/remote_test.go
new file mode 100644
index 0000000..1b0cd55
--- /dev/null
+++ b/pkg/gitrepo/remote_test.go
@@ -0,0 +1,49 @@
+package gitrepo
+
+import "testing"
+
+func TestNormalizeRemote(t *testing.T) {
+ remote, err := NormalizeRemote("github", "https://github.com", "https://github.com/Owner/Repo.git")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if remote.Name != "Repo" || remote.Normalized != "github.com/owner/repo" {
+ t.Fatalf("unexpected remote: %#v", remote)
+ }
+}
+
+func TestNormalizeRemoteRejectsCredentialAndWrongHost(t *testing.T) {
+ for _, value := range []string{"https://token@github.com/o/r", "https://gitlab.com/o/r"} {
+ if _, err := NormalizeRemote("github", "https://github.com", value); err == nil {
+ t.Fatalf("expected %q to fail", value)
+ }
+ }
+}
+
+func TestNormalizePublicRemoteInfersProvider(t *testing.T) {
+ tests := []struct {
+ url string
+ provider string
+ }{
+ {url: "https://github.com/Owner/Repo", provider: "github"},
+ {url: "https://gitlab.com/Group/Repo.git", provider: "gitlab"},
+ }
+
+ for _, test := range tests {
+ remote, err := NormalizePublicRemote(test.url)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if remote.ProviderType != test.provider || remote.Name != "Repo" {
+ t.Fatalf("NormalizePublicRemote(%q) = %#v", test.url, remote)
+ }
+ }
+}
+
+func TestNormalizePublicRemoteRejectsUnsupportedHostAndCredential(t *testing.T) {
+ for _, value := range []string{"https://code.example.com/o/r", "https://token@github.com/o/r"} {
+ if _, err := NormalizePublicRemote(value); err == nil {
+ t.Fatalf("expected %q to fail", value)
+ }
+ }
+}
diff --git a/pkg/gitrepo/size.go b/pkg/gitrepo/size.go
new file mode 100644
index 0000000..b34cc0f
--- /dev/null
+++ b/pkg/gitrepo/size.go
@@ -0,0 +1,26 @@
+package gitrepo
+
+import (
+ "io/fs"
+ "path/filepath"
+)
+
+func directorySize(root string) (int64, error) {
+ var size int64
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.Type().IsRegular() {
+ info, err := entry.Info()
+ if err != nil {
+ return err
+ }
+ size += info.Size()
+ }
+
+ return nil
+ })
+
+ return size, err
+}
diff --git a/pkg/gitrepo/snapshot.go b/pkg/gitrepo/snapshot.go
new file mode 100644
index 0000000..1c6f17b
--- /dev/null
+++ b/pkg/gitrepo/snapshot.go
@@ -0,0 +1,44 @@
+package gitrepo
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/google/uuid"
+)
+
+func (m *DefaultManager) ReleaseSnapshot(ctx context.Context, repositoryID, snapshotID uuid.UUID, commitSHA string) error {
+ unlock := m.lock(repositoryID)
+ defer unlock()
+
+ if !commitPattern.MatchString(commitSHA) {
+ return ErrInvalidCommitID
+ }
+
+ repositoryPath := m.Path(repositoryID)
+ if _, err := os.Stat(repositoryPath); errors.Is(err, os.ErrNotExist) {
+ return nil
+ } else if err != nil {
+ return fmt.Errorf("inspect managed repository: %w", err)
+ }
+ snapshotRef := "refs/mooncode/snapshots/" + snapshotID.String()
+ current, err := m.run(ctx, nil, "--git-dir", repositoryPath, "for-each-ref", "--format=%(objectname)", snapshotRef)
+ if err != nil {
+ return fmt.Errorf("inspect snapshot pin: %w", err)
+ }
+ current = strings.TrimSpace(current)
+ if current == "" {
+ return nil
+ }
+ if !strings.EqualFold(current, commitSHA) {
+ return fmt.Errorf("release snapshot pin: expected commit %s but found %s", commitSHA, current)
+ }
+ if _, err := m.run(ctx, nil, "--git-dir", repositoryPath, "update-ref", "-d", snapshotRef, current); err != nil {
+ return fmt.Errorf("release snapshot pin: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/gitrepo/snapshot_test.go b/pkg/gitrepo/snapshot_test.go
new file mode 100644
index 0000000..5999fe4
--- /dev/null
+++ b/pkg/gitrepo/snapshot_test.go
@@ -0,0 +1,88 @@
+package gitrepo
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestReleaseSnapshotRemovesOnlyExpectedPinAndIsIdempotent(t *testing.T) {
+ manager, err := NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ repositoryID, snapshotID := uuid.New(), uuid.New()
+ if _, err := manager.run(context.Background(), nil, "init", "--bare", manager.Path(repositoryID)); err != nil {
+ t.Fatal(err)
+ }
+ commitSHA, err := manager.runInput(context.Background(), "snapshot content", nil, "--git-dir", manager.Path(repositoryID), "hash-object", "-w", "--stdin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ commitSHA = strings.TrimSpace(commitSHA)
+ ref := "refs/mooncode/snapshots/" + snapshotID.String()
+ if _, err := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "update-ref", ref, commitSHA); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := manager.ReleaseSnapshot(context.Background(), repositoryID, snapshotID, commitSHA); err != nil {
+ t.Fatal(err)
+ }
+ if err := manager.ReleaseSnapshot(context.Background(), repositoryID, snapshotID, commitSHA); err != nil {
+ t.Fatalf("second release must be idempotent: %v", err)
+ }
+ remaining, err := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "for-each-ref", "--format=%(refname)", ref)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(remaining) != "" {
+ t.Fatalf("snapshot ref still exists: %q", remaining)
+ }
+}
+
+func TestReleaseSnapshotRejectsUnexpectedCommit(t *testing.T) {
+ manager, err := NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ repositoryID, snapshotID := uuid.New(), uuid.New()
+ if _, err := manager.run(context.Background(), nil, "init", "--bare", manager.Path(repositoryID)); err != nil {
+ t.Fatal(err)
+ }
+ commitSHA, err := manager.runInput(context.Background(), "snapshot content", nil, "--git-dir", manager.Path(repositoryID), "hash-object", "-w", "--stdin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ commitSHA = strings.TrimSpace(commitSHA)
+ ref := "refs/mooncode/snapshots/" + snapshotID.String()
+ if _, err := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "update-ref", ref, commitSHA); err != nil {
+ t.Fatal(err)
+ }
+
+ err = manager.ReleaseSnapshot(context.Background(), repositoryID, snapshotID, strings.Repeat("f", 40))
+ if err == nil {
+ t.Fatal("unexpected commit was accepted")
+ }
+ remaining, lookupErr := manager.run(context.Background(), nil, "--git-dir", manager.Path(repositoryID), "for-each-ref", "--format=%(objectname)", ref)
+ if lookupErr != nil {
+ t.Fatal(lookupErr)
+ }
+ if strings.TrimSpace(remaining) != commitSHA {
+ t.Fatal("snapshot ref changed after rejected release")
+ }
+}
+
+func TestReleaseSnapshotTreatsMissingMirrorAsAlreadyReleased(t *testing.T) {
+ manager, err := NewManager(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if err := manager.ReleaseSnapshot(
+ context.Background(), uuid.New(), uuid.New(), strings.Repeat("a", 40),
+ ); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/pkg/scm/scm.go b/pkg/scm/scm.go
deleted file mode 100644
index 1241ba1..0000000
--- a/pkg/scm/scm.go
+++ /dev/null
@@ -1,210 +0,0 @@
-// Package scm defines source-control provider registration and remote URL policy.
-package scm
-
-import (
- "context"
- "errors"
- "fmt"
- "net"
- "net/url"
- "sort"
- "strings"
-)
-
-var (
- ErrInvalidRemote = errors.New("scm: invalid remote")
- ErrUnsafeAddress = errors.New("scm: unsafe network address")
- ErrUnknownProvider = errors.New("scm: unknown provider")
-)
-
-type Provider interface {
- Type() string
- Validate(ctx context.Context, remote string) (Remote, error)
-}
-
-type Remote struct {
- Provider string
- URL string
- Host string
- Path string
-}
-
-type Registry struct {
- providers map[string]Provider
-}
-
-type RegistryOption func(*Registry) error
-
-func WithProvider(provider Provider) RegistryOption {
- return func(registry *Registry) error { return registry.Register(provider) }
-}
-
-func NewRegistry(opts ...RegistryOption) (*Registry, error) {
- registry := &Registry{providers: make(map[string]Provider)}
- for _, option := range opts {
- if option != nil {
- if err := option(registry); err != nil {
- return nil, err
- }
- }
- }
- return registry, nil
-}
-
-func (r *Registry) Register(provider Provider) error {
- if provider == nil || strings.TrimSpace(provider.Type()) == "" {
- return errors.New("scm provider type is required")
- }
- providerType := strings.ToLower(provider.Type())
- if _, exists := r.providers[providerType]; exists {
- return fmt.Errorf("register SCM provider %q: duplicate type", providerType)
- }
- r.providers[providerType] = provider
- return nil
-}
-
-func (r *Registry) Provider(providerType string) (Provider, error) {
- provider, ok := r.providers[strings.ToLower(strings.TrimSpace(providerType))]
- if !ok {
- return nil, fmt.Errorf("%w: %s", ErrUnknownProvider, providerType)
- }
- return provider, nil
-}
-
-func (r *Registry) Types() []string {
- result := make([]string, 0, len(r.providers))
- for providerType := range r.providers {
- result = append(result, providerType)
- }
- sort.Strings(result)
- return result
-}
-
-type Resolver interface {
- LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
-}
-
-type PolicyOption func(*URLPolicy)
-
-func WithResolver(resolver Resolver) PolicyOption {
- return func(policy *URLPolicy) {
- if resolver != nil {
- policy.resolver = resolver
- }
- }
-}
-
-func WithAllowedPort(port string) PolicyOption {
- return func(policy *URLPolicy) {
- if port != "" {
- policy.allowedPorts[port] = struct{}{}
- }
- }
-}
-
-func WithAllowedNetwork(network *net.IPNet) PolicyOption {
- return func(policy *URLPolicy) {
- if network != nil {
- policy.allowedNetworks = append(policy.allowedNetworks, network)
- }
- }
-}
-
-type URLPolicy struct {
- resolver Resolver
- allowedPorts map[string]struct{}
- allowedNetworks []*net.IPNet
-}
-
-func NewURLPolicy(opts ...PolicyOption) *URLPolicy {
- policy := &URLPolicy{resolver: net.DefaultResolver, allowedPorts: map[string]struct{}{"443": {}}}
- for _, option := range opts {
- if option != nil {
- option(policy)
- }
- }
- return policy
-}
-
-func (p *URLPolicy) Validate(ctx context.Context, raw string) (*url.URL, error) {
- parsed, err := url.Parse(strings.TrimSpace(raw))
- if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || parsed.User != nil {
- return nil, ErrInvalidRemote
- }
- if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || parsed.Path == "/" {
- return nil, ErrInvalidRemote
- }
- port := parsed.Port()
- if port == "" {
- port = "443"
- }
- if _, ok := p.allowedPorts[port]; !ok {
- return nil, ErrInvalidRemote
- }
- addresses, err := p.resolver.LookupIPAddr(ctx, parsed.Hostname())
- if err != nil {
- return nil, fmt.Errorf("resolve SCM remote host: %w", err)
- }
- if len(addresses) == 0 {
- return nil, errors.New("resolve SCM remote host: no addresses returned")
- }
- for _, address := range addresses {
- if unsafeIP(address.IP) && !p.networkAllowed(address.IP) {
- return nil, fmt.Errorf("%w: host resolves to a private or special-use address", ErrUnsafeAddress)
- }
- }
- parsed.Scheme = "https"
- parsed.Host = strings.ToLower(parsed.Host)
- parsed.Path = strings.TrimSuffix(parsed.Path, "/")
- parsed.RawPath = ""
- return parsed, nil
-}
-
-func (p *URLPolicy) networkAllowed(ip net.IP) bool {
- for _, network := range p.allowedNetworks {
- if network.Contains(ip) {
- return true
- }
- }
- return false
-}
-
-func unsafeIP(ip net.IP) bool {
- return ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
- ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
-}
-
-type HTTPSProvider struct {
- providerType string
- policy *URLPolicy
- hostSuffixes []string
-}
-
-func NewHTTPSProvider(providerType string, policy *URLPolicy, hostSuffixes ...string) *HTTPSProvider {
- if policy == nil {
- policy = NewURLPolicy()
- }
- return &HTTPSProvider{providerType: strings.ToLower(providerType), policy: policy, hostSuffixes: hostSuffixes}
-}
-
-func (p *HTTPSProvider) Type() string { return p.providerType }
-
-func (p *HTTPSProvider) Validate(ctx context.Context, raw string) (Remote, error) {
- parsed, err := p.policy.Validate(ctx, raw)
- if err != nil {
- return Remote{}, err
- }
- if len(p.hostSuffixes) > 0 {
- allowed := false
- for _, suffix := range p.hostSuffixes {
- if parsed.Hostname() == suffix || strings.HasSuffix(parsed.Hostname(), "."+suffix) {
- allowed = true
- break
- }
- }
- if !allowed {
- return Remote{}, ErrInvalidRemote
- }
- }
- return Remote{Provider: p.providerType, URL: parsed.String(), Host: parsed.Hostname(), Path: parsed.Path}, nil
-}
diff --git a/pkg/scm/scm_test.go b/pkg/scm/scm_test.go
deleted file mode 100644
index 767374d..0000000
--- a/pkg/scm/scm_test.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package scm
-
-import (
- "context"
- "net"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-type fixedResolver []net.IPAddr
-
-func (r fixedResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) { return r, nil }
-
-func TestURLPolicyAllowsPublicHTTPS(t *testing.T) {
- policy := NewURLPolicy(WithResolver(fixedResolver{{IP: net.ParseIP("8.8.8.8")}}))
- remote, err := policy.Validate(context.Background(), "https://GitHub.com/moon/code.git")
- require.NoError(t, err)
- require.Equal(t, "github.com", remote.Hostname())
-}
-
-func TestURLPolicyRejectsPrivateAddressAndCredentials(t *testing.T) {
- privatePolicy := NewURLPolicy(WithResolver(fixedResolver{{IP: net.ParseIP("127.0.0.1")}}))
- _, err := privatePolicy.Validate(context.Background(), "https://git.example.com/moon/code.git")
- require.ErrorIs(t, err, ErrUnsafeAddress)
-
- publicPolicy := NewURLPolicy(WithResolver(fixedResolver{{IP: net.ParseIP("8.8.8.8")}}))
- _, err = publicPolicy.Validate(context.Background(), "https://token@git.example.com/moon/code.git")
- require.ErrorIs(t, err, ErrInvalidRemote)
-}
-
-func TestURLPolicyAllowsExplicitPrivateNetwork(t *testing.T) {
- _, network, err := net.ParseCIDR("10.20.0.0/16")
- require.NoError(t, err)
- policy := NewURLPolicy(
- WithResolver(fixedResolver{{IP: net.ParseIP("10.20.1.5")}}),
- WithAllowedNetwork(network),
- )
- remote, err := policy.Validate(context.Background(), "https://git.internal.example/team/code.git")
- require.NoError(t, err)
- require.Equal(t, "git.internal.example", remote.Hostname())
-}
-
-func TestRegistryRejectsDuplicateProvider(t *testing.T) {
- provider := NewHTTPSProvider("github", NewURLPolicy())
- _, err := NewRegistry(WithProvider(provider), WithProvider(provider))
- require.ErrorContains(t, err, "duplicate")
-}
diff --git a/pkg/storage/capabilities.go b/pkg/storage/capabilities.go
deleted file mode 100644
index 7bda79d..0000000
--- a/pkg/storage/capabilities.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package storage
-
-import (
- "context"
- "time"
-)
-
-type URLSigner interface {
- PresignGet(ctx context.Context, location Location, ttl time.Duration) (string, error)
-}
-
-type ListOptions struct {
- Cursor string
- Limit int
-}
-
-type ListOption func(*ListOptions)
-
-type Page struct {
- Items []Metadata
- NextCursor string
-}
-
-type Lister interface {
- List(ctx context.Context, namespace, prefix string, opts ...ListOption) (Page, error)
-}
diff --git a/pkg/storage/s3/integration_test.go b/pkg/storage/s3/integration_test.go
deleted file mode 100644
index 598d8da..0000000
--- a/pkg/storage/s3/integration_test.go
+++ /dev/null
@@ -1,55 +0,0 @@
-//go:build integration
-
-package s3
-
-import (
- "context"
- "fmt"
- "net/http"
- "sync/atomic"
- "testing"
- "time"
-
- "github.com/mooncode-ai/mooncode/pkg/storage/storagetest"
- "github.com/stretchr/testify/require"
- "github.com/testcontainers/testcontainers-go"
- "github.com/testcontainers/testcontainers-go/wait"
-)
-
-func TestMinIOStorageContract(t *testing.T) {
- ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute)
- defer cancel()
- container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
- ContainerRequest: testcontainers.ContainerRequest{
- Image: "minio/minio:RELEASE.2025-04-22T22-12-26Z",
- Env: map[string]string{"MINIO_ROOT_USER": "mooncode", "MINIO_ROOT_PASSWORD": "mooncode-development-secret"},
- Cmd: []string{"server", "/data", "--console-address", ":9001"},
- ExposedPorts: []string{"9000/tcp"},
- WaitingFor: wait.ForHTTP("/minio/health/ready").WithPort("9000/tcp").WithStartupTimeout(90 * time.Second),
- },
- Started: true,
- })
- require.NoError(t, err)
- t.Cleanup(func() { require.NoError(t, container.Terminate(context.Background())) })
- host, err := container.Host(ctx)
- require.NoError(t, err)
- port, err := container.MappedPort(ctx, "9000/tcp")
- require.NoError(t, err)
-
- transport := &countingTransport{next: http.DefaultTransport.(*http.Transport).Clone()}
- store, err := New(fmt.Sprintf("%s:%s", host, port.Port()), "mooncode", "mooncode-development-secret", WithRegion("us-east-1"), WithTransport(transport))
- require.NoError(t, err)
- require.NoError(t, store.EnsureBucket(t.Context(), "mooncode-contract"))
- storagetest.Run(t, store, "mooncode-contract")
- require.Positive(t, transport.requests.Load())
-}
-
-type countingTransport struct {
- next http.RoundTripper
- requests atomic.Int64
-}
-
-func (transport *countingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
- transport.requests.Add(1)
- return transport.next.RoundTrip(request)
-}
diff --git a/pkg/storage/s3/s3.go b/pkg/storage/s3/s3.go
deleted file mode 100644
index efdeaaa..0000000
--- a/pkg/storage/s3/s3.go
+++ /dev/null
@@ -1,212 +0,0 @@
-// Package s3 implements storage.Store for S3-compatible services such as MinIO.
-package s3
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "net/http"
- "strings"
-
- "github.com/minio/minio-go/v7"
- "github.com/minio/minio-go/v7/pkg/credentials"
- storagecore "github.com/mooncode-ai/mooncode/pkg/storage"
-)
-
-type Option func(*options)
-
-type options struct {
- secure bool
- region string
- sessionToken string
- transport http.RoundTripper
-}
-
-func WithSecure(secure bool) Option {
- return func(cfg *options) { cfg.secure = secure }
-}
-
-func WithRegion(region string) Option {
- return func(cfg *options) { cfg.region = region }
-}
-
-func WithSessionToken(token string) Option {
- return func(cfg *options) { cfg.sessionToken = token }
-}
-
-func WithTransport(transport http.RoundTripper) Option {
- return func(cfg *options) {
- if transport != nil {
- cfg.transport = transport
- }
- }
-}
-
-type Store struct {
- client *minio.Client
-}
-
-func New(endpoint, accessKey, secretKey string, opts ...Option) (*Store, error) {
- if strings.TrimSpace(endpoint) == "" {
- return nil, errors.New("s3 endpoint is required")
- }
- cfg := options{}
- for _, option := range opts {
- if option != nil {
- option(&cfg)
- }
- }
- client, err := minio.New(endpoint, &minio.Options{
- Creds: credentials.NewStaticV4(accessKey, secretKey, cfg.sessionToken),
- Secure: cfg.secure, Region: cfg.region, Transport: cfg.transport,
- })
- if err != nil {
- return nil, fmt.Errorf("create S3 client: %w", err)
- }
- return &Store{client: client}, nil
-}
-
-func (s *Store) EnsureBucket(ctx context.Context, namespace string) error {
- location := storagecore.Location{Namespace: namespace, Key: "bucket-check"}
- if err := location.Validate(); err != nil {
- return err
- }
- exists, err := s.client.BucketExists(ctx, namespace)
- if err != nil {
- return fmt.Errorf("check S3 bucket %q: %w", namespace, err)
- }
- if exists {
- return nil
- }
- if err := s.client.MakeBucket(ctx, namespace, minio.MakeBucketOptions{}); err != nil {
- exists, checkErr := s.client.BucketExists(ctx, namespace)
- if checkErr == nil && exists {
- return nil
- }
- return fmt.Errorf("create S3 bucket %q: %w", namespace, err)
- }
- return nil
-}
-
-// CheckBuckets verifies that every configured namespace remains reachable.
-func (s *Store) CheckBuckets(ctx context.Context, namespaces ...string) error {
- for _, namespace := range namespaces {
- location := storagecore.Location{Namespace: namespace, Key: "health-check"}
- if err := location.Validate(); err != nil {
- return err
- }
- exists, err := s.client.BucketExists(ctx, namespace)
- if err != nil {
- return fmt.Errorf("check S3 bucket %q: %w", namespace, err)
- }
- if !exists {
- return fmt.Errorf("check S3 bucket %q: bucket does not exist", namespace)
- }
- }
- return nil
-}
-
-func (s *Store) Put(ctx context.Context, location storagecore.Location, body io.Reader, size int64, opts ...storagecore.PutOption) (storagecore.Metadata, error) {
- if err := location.Validate(); err != nil {
- return storagecore.Metadata{}, err
- }
- if size < 0 {
- return storagecore.Metadata{}, fmt.Errorf("%w: size cannot be negative", storagecore.ErrInvalidKey)
- }
- putOptions := storagecore.ResolvePutOptions(opts...)
- userMetadata := make(map[string]string)
- if putOptions.SHA256 != "" {
- userMetadata["sha256"] = putOptions.SHA256
- }
- s3Options := minio.PutObjectOptions{
- ContentType: putOptions.ContentType, UserMetadata: userMetadata,
- }
- if putOptions.CreateOnly {
- // S3 conditional writes make CreateOnly atomic across processes. A
- // preceding Stat has a race in which two writers can both overwrite the
- // same create-only object key.
- s3Options.SetMatchETagExcept("*")
- }
- info, err := s.client.PutObject(ctx, location.Namespace, location.Key, body, size, s3Options)
- if err != nil {
- return storagecore.Metadata{}, mapError("put", location, err)
- }
- return storagecore.Metadata{
- Location: location, Size: info.Size, SHA256: putOptions.SHA256,
- ETag: info.ETag, Version: info.VersionID, ContentType: putOptions.ContentType,
- }, nil
-}
-
-func (s *Store) Open(ctx context.Context, location storagecore.Location, opts ...storagecore.OpenOption) (io.ReadCloser, error) {
- if err := location.Validate(); err != nil {
- return nil, err
- }
- // GetObject is lazy and may not report a missing object until the first
- // read. Use StatObject for eager error semantics; calling Object.Stat after
- // GetObject would discard the requested byte range in minio-go.
- if _, err := s.client.StatObject(ctx, location.Namespace, location.Key, minio.StatObjectOptions{}); err != nil {
- return nil, mapError("open", location, err)
- }
- openOptions := storagecore.ResolveOpenOptions(opts...)
- getOptions := minio.GetObjectOptions{}
- if openOptions.Offset < 0 || openOptions.Length < 0 {
- return nil, fmt.Errorf("%w: range cannot be negative", storagecore.ErrInvalidKey)
- }
- if openOptions.Offset > 0 || openOptions.Length > 0 {
- end := int64(0)
- if openOptions.Length > 0 {
- end = openOptions.Offset + openOptions.Length - 1
- }
- if err := getOptions.SetRange(openOptions.Offset, end); err != nil {
- return nil, fmt.Errorf("set S3 range: %w", err)
- }
- }
- object, err := s.client.GetObject(ctx, location.Namespace, location.Key, getOptions)
- if err != nil {
- return nil, mapError("open", location, err)
- }
- return object, nil
-}
-
-func (s *Store) Stat(ctx context.Context, location storagecore.Location) (storagecore.Metadata, error) {
- if err := location.Validate(); err != nil {
- return storagecore.Metadata{}, err
- }
- info, err := s.client.StatObject(ctx, location.Namespace, location.Key, minio.StatObjectOptions{})
- if err != nil {
- return storagecore.Metadata{}, mapError("stat", location, err)
- }
- return storagecore.Metadata{
- Location: location, Size: info.Size, SHA256: info.Metadata.Get("X-Amz-Meta-Sha256"),
- ETag: info.ETag, Version: info.VersionID, ContentType: info.ContentType,
- }, nil
-}
-
-func (s *Store) Delete(ctx context.Context, location storagecore.Location) error {
- if err := location.Validate(); err != nil {
- return err
- }
- if err := s.client.RemoveObject(ctx, location.Namespace, location.Key, minio.RemoveObjectOptions{}); err != nil {
- mapped := mapError("delete", location, err)
- if errors.Is(mapped, storagecore.ErrNotFound) {
- return nil
- }
- return mapped
- }
- return nil
-}
-
-func mapError(operation string, location storagecore.Location, err error) error {
- response := minio.ToErrorResponse(err)
- switch response.Code {
- case "NoSuchKey", "NoSuchObject", "NoSuchBucket", "NotFound":
- return fmt.Errorf("%s %s/%s: %w", operation, location.Namespace, location.Key, storagecore.ErrNotFound)
- case "PreconditionFailed":
- return fmt.Errorf("%s %s/%s: %w", operation, location.Namespace, location.Key, storagecore.ErrAlreadyExists)
- default:
- return fmt.Errorf("%s S3 object: %w", operation, err)
- }
-}
-
-var _ storagecore.Store = (*Store)(nil)
diff --git a/pkg/storage/s3/s3_test.go b/pkg/storage/s3/s3_test.go
deleted file mode 100644
index c3c9787..0000000
--- a/pkg/storage/s3/s3_test.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package s3
-
-import (
- "bytes"
- "context"
- "errors"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- storagecore "github.com/mooncode-ai/mooncode/pkg/storage"
- "github.com/stretchr/testify/require"
-)
-
-func TestPutCreateOnlyUsesAtomicConditionalWrite(t *testing.T) {
- var condition string
- server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
- condition = request.Header.Get("If-None-Match")
- writer.Header().Set("ETag", `"created"`)
- writer.WriteHeader(http.StatusOK)
- }))
- defer server.Close()
-
- store, err := New(strings.TrimPrefix(server.URL, "http://"), "access", "secret", WithRegion("us-east-1"))
- require.NoError(t, err)
- body := []byte("immutable")
- _, err = store.Put(context.Background(), storagecore.Location{Namespace: "artifacts", Key: "one.json"}, bytes.NewReader(body), int64(len(body)), storagecore.WithCreateOnly())
- require.NoError(t, err)
- require.Equal(t, "*", condition)
-}
-
-func TestPutCreateOnlyMapsPreconditionFailure(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
- writer.Header().Set("Content-Type", "application/xml")
- writer.WriteHeader(http.StatusPreconditionFailed)
- _, _ = writer.Write([]byte(`PreconditionFailedexists`))
- }))
- defer server.Close()
-
- store, err := New(strings.TrimPrefix(server.URL, "http://"), "access", "secret", WithRegion("us-east-1"))
- require.NoError(t, err)
- body := []byte("immutable")
- _, err = store.Put(context.Background(), storagecore.Location{Namespace: "artifacts", Key: "one.json"}, bytes.NewReader(body), int64(len(body)), storagecore.WithCreateOnly())
- require.Error(t, err)
- require.True(t, errors.Is(err, storagecore.ErrAlreadyExists), err)
-}
diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go
deleted file mode 100644
index 4422885..0000000
--- a/pkg/storage/storage.go
+++ /dev/null
@@ -1,132 +0,0 @@
-// Package storage defines the portable binary storage contract used by MoonCode.
-// It deliberately contains no tenant, authorization, or database concepts.
-package storage
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "path"
- "strings"
-)
-
-var (
- ErrNotFound = errors.New("storage: not found")
- ErrAlreadyExists = errors.New("storage: already exists")
- ErrInvalidKey = errors.New("storage: invalid key")
-)
-
-type Location struct {
- Namespace string
- Key string
-}
-
-func (l Location) Validate() error {
- if strings.TrimSpace(l.Namespace) == "" {
- return fmt.Errorf("%w: namespace is required", ErrInvalidKey)
- }
- if strings.ContainsAny(l.Namespace, "\\/\x00") || l.Namespace == "." || l.Namespace == ".." {
- return fmt.Errorf("%w: invalid namespace", ErrInvalidKey)
- }
- if l.Key == "" || strings.ContainsRune(l.Key, '\x00') || strings.ContainsRune(l.Key, '\\') {
- return fmt.Errorf("%w: invalid key", ErrInvalidKey)
- }
- if strings.HasPrefix(l.Key, "/") || path.Clean(l.Key) != l.Key || l.Key == "." || l.Key == ".." {
- return fmt.Errorf("%w: key must be a clean relative slash path", ErrInvalidKey)
- }
- for _, segment := range strings.Split(l.Key, "/") {
- if segment == "." || segment == ".." {
- return fmt.Errorf("%w: key contains a traversal segment", ErrInvalidKey)
- }
- }
- return nil
-}
-
-type Metadata struct {
- Location Location
- Size int64
- SHA256 string
- ETag string
- Version string
- ContentType string
-}
-
-type Store interface {
- Put(ctx context.Context, location Location, body io.Reader, size int64, opts ...PutOption) (Metadata, error)
- Open(ctx context.Context, location Location, opts ...OpenOption) (io.ReadCloser, error)
- Stat(ctx context.Context, location Location) (Metadata, error)
- Delete(ctx context.Context, location Location) error
-}
-
-type PutOptions struct {
- ContentType string
- SHA256 string
- CreateOnly bool
-}
-
-type PutOption func(*PutOptions)
-
-func WithContentType(contentType string) PutOption {
- return func(options *PutOptions) {
- options.ContentType = contentType
- }
-}
-
-func WithSHA256(checksum string) PutOption {
- return func(options *PutOptions) {
- options.SHA256 = checksum
- }
-}
-
-func WithCreateOnly() PutOption {
- return func(options *PutOptions) {
- options.CreateOnly = true
- }
-}
-
-func ResolvePutOptions(opts ...PutOption) PutOptions {
- var options PutOptions
- for _, option := range opts {
- if option != nil {
- option(&options)
- }
- }
- return options
-}
-
-type OpenOptions struct {
- Offset int64
- Length int64
-}
-
-type OpenOption func(*OpenOptions)
-
-func WithRange(offset, length int64) OpenOption {
- return func(options *OpenOptions) {
- options.Offset = offset
- options.Length = length
- }
-}
-
-func ResolveOpenOptions(opts ...OpenOption) OpenOptions {
- var options OpenOptions
- for _, option := range opts {
- if option != nil {
- option(&options)
- }
- }
- return options
-}
-
-type Wrapper func(Store) Store
-
-// Wrap applies wrappers in declaration order: the first wrapper is outermost.
-func Wrap(store Store, wrappers ...Wrapper) Store {
- for index := len(wrappers) - 1; index >= 0; index-- {
- if wrappers[index] != nil {
- store = wrappers[index](store)
- }
- }
- return store
-}
diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go
deleted file mode 100644
index d6a621d..0000000
--- a/pkg/storage/storage_test.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package storage
-
-import (
- "context"
- "io"
- "testing"
-
- "github.com/stretchr/testify/require"
-)
-
-func TestLocationValidate(t *testing.T) {
- tests := []struct {
- name string
- location Location
- wantErr bool
- }{
- {name: "valid", location: Location{Namespace: "artifacts", Key: "workspaces/one/report.json"}},
- {name: "empty namespace", location: Location{Key: "object"}, wantErr: true},
- {name: "absolute", location: Location{Namespace: "artifacts", Key: "/etc/passwd"}, wantErr: true},
- {name: "traversal", location: Location{Namespace: "artifacts", Key: "../passwd"}, wantErr: true},
- {name: "unclean", location: Location{Namespace: "artifacts", Key: "a//b"}, wantErr: true},
- {name: "backslash", location: Location{Namespace: "artifacts", Key: `a\b`}, wantErr: true},
- }
-
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- err := test.location.Validate()
- if test.wantErr {
- require.ErrorIs(t, err, ErrInvalidKey)
- return
- }
- require.NoError(t, err)
- })
- }
-}
-
-func TestWrapUsesDeclarationOrder(t *testing.T) {
- var calls []string
- base := stubStore{onStat: func() { calls = append(calls, "base") }}
- wrap := func(name string) Wrapper {
- return func(next Store) Store {
- return stubStore{onStat: func() {
- calls = append(calls, name+":before")
- _, _ = next.Stat(context.Background(), Location{})
- calls = append(calls, name+":after")
- }}
- }
- }
-
- _, _ = Wrap(base, wrap("first"), wrap("second")).Stat(context.Background(), Location{})
- require.Equal(t, []string{"first:before", "second:before", "base", "second:after", "first:after"}, calls)
-}
-
-type stubStore struct {
- onStat func()
-}
-
-func (stubStore) Put(context.Context, Location, io.Reader, int64, ...PutOption) (Metadata, error) {
- return Metadata{}, nil
-}
-
-func (stubStore) Open(context.Context, Location, ...OpenOption) (io.ReadCloser, error) {
- return nil, nil
-}
-
-func (s stubStore) Stat(context.Context, Location) (Metadata, error) {
- if s.onStat != nil {
- s.onStat()
- }
- return Metadata{}, nil
-}
-
-func (stubStore) Delete(context.Context, Location) error { return nil }
diff --git a/pkg/storage/storagetest/contract.go b/pkg/storage/storagetest/contract.go
deleted file mode 100644
index 516348b..0000000
--- a/pkg/storage/storagetest/contract.go
+++ /dev/null
@@ -1,119 +0,0 @@
-// Package storagetest contains a reusable contract suite for storage.Store
-// adapters. Adapter integration tests can call Run against their real backend.
-package storagetest
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "sync"
- "testing"
- "time"
-
- "github.com/mooncode-ai/mooncode/pkg/storage"
-)
-
-func Run(t *testing.T, store storage.Store, namespace string) {
- t.Helper()
- prefix := fmt.Sprintf("contract/%d", time.Now().UnixNano())
-
- t.Run("put open stat range and delete", func(t *testing.T) {
- location := storage.Location{Namespace: namespace, Key: prefix + "/object.bin"}
- body := []byte("mooncode-storage-contract")
- metadata, err := store.Put(t.Context(), location, bytes.NewReader(body), int64(len(body)), storage.WithContentType("application/octet-stream"), storage.WithSHA256("fixture-checksum"), storage.WithCreateOnly())
- if err != nil {
- t.Fatalf("put: %v", err)
- }
- if metadata.Location != location {
- t.Fatalf("put location = %#v, want %#v", metadata.Location, location)
- }
- stat, err := store.Stat(t.Context(), location)
- if err != nil {
- t.Fatalf("stat: %v", err)
- }
- if stat.Size != int64(len(body)) || stat.SHA256 != "fixture-checksum" {
- t.Fatalf("stat metadata = %#v", stat)
- }
- reader, err := store.Open(t.Context(), location)
- if err != nil {
- t.Fatalf("open: %v", err)
- }
- contents, readErr := io.ReadAll(reader)
- closeErr := reader.Close()
- if readErr != nil || closeErr != nil || !bytes.Equal(contents, body) {
- t.Fatalf("read = %q, read error = %v, close error = %v", contents, readErr, closeErr)
- }
- rangeReader, err := store.Open(t.Context(), location, storage.WithRange(4, 4))
- if err != nil {
- t.Fatalf("open range: %v", err)
- }
- ranged, readErr := io.ReadAll(rangeReader)
- closeErr = rangeReader.Close()
- if readErr != nil || closeErr != nil || string(ranged) != "code" {
- t.Fatalf("range = %q, read error = %v, close error = %v", ranged, readErr, closeErr)
- }
- if _, err := store.Put(t.Context(), location, bytes.NewReader(body), int64(len(body)), storage.WithCreateOnly()); !errors.Is(err, storage.ErrAlreadyExists) {
- t.Fatalf("second create-only put error = %v, want ErrAlreadyExists", err)
- }
- if err := store.Delete(t.Context(), location); err != nil {
- t.Fatalf("delete: %v", err)
- }
- if err := store.Delete(t.Context(), location); err != nil {
- t.Fatalf("idempotent delete: %v", err)
- }
- if _, err := store.Stat(t.Context(), location); !errors.Is(err, storage.ErrNotFound) {
- t.Fatalf("stat deleted error = %v, want ErrNotFound", err)
- }
- })
-
- t.Run("atomic create only", func(t *testing.T) {
- location := storage.Location{Namespace: namespace, Key: prefix + "/atomic.bin"}
- start := make(chan struct{})
- errorsChannel := make(chan error, 2)
- var group sync.WaitGroup
- for _, value := range []string{"first", "second"} {
- group.Add(1)
- go func(value string) {
- defer group.Done()
- <-start
- _, err := store.Put(t.Context(), location, bytes.NewBufferString(value), int64(len(value)), storage.WithCreateOnly())
- errorsChannel <- err
- }(value)
- }
- close(start)
- group.Wait()
- close(errorsChannel)
- succeeded, alreadyExists := 0, 0
- for err := range errorsChannel {
- switch {
- case err == nil:
- succeeded++
- case errors.Is(err, storage.ErrAlreadyExists):
- alreadyExists++
- default:
- t.Fatalf("create-only error: %v", err)
- }
- }
- if succeeded != 1 || alreadyExists != 1 {
- t.Fatalf("successes = %d, already-exists = %d", succeeded, alreadyExists)
- }
- if err := store.Delete(t.Context(), location); err != nil {
- t.Fatalf("delete atomic fixture: %v", err)
- }
- })
-
- t.Run("validation and cancellation", func(t *testing.T) {
- invalid := storage.Location{Namespace: namespace, Key: "../escape"}
- if _, err := store.Stat(t.Context(), invalid); !errors.Is(err, storage.ErrInvalidKey) {
- t.Fatalf("invalid key error = %v, want ErrInvalidKey", err)
- }
- ctx, cancel := context.WithCancel(t.Context())
- cancel()
- location := storage.Location{Namespace: namespace, Key: prefix + "/cancelled.bin"}
- if _, err := store.Put(ctx, location, bytes.NewBufferString("cancelled"), 9); err == nil {
- t.Fatal("cancelled put unexpectedly succeeded")
- }
- })
-}
diff --git a/scripts/e2e/live-smoke.sh b/scripts/e2e/live-smoke.sh
new file mode 100755
index 0000000..eeabfcc
--- /dev/null
+++ b/scripts/e2e/live-smoke.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+base_url="${MOONCODE_E2E_BASE_URL:-}"
+session_cookie="${MOONCODE_E2E_SESSION_COOKIE:-}"
+
+if [[ -z "${base_url}" ]]; then
+ echo "MOONCODE_E2E_BASE_URL is required" >&2
+ exit 2
+fi
+if [[ "${base_url}" != http://* && "${base_url}" != https://* ]]; then
+ echo "MOONCODE_E2E_BASE_URL must use http or https" >&2
+ exit 2
+fi
+if [[ -z "${session_cookie}" ]]; then
+ echo "MOONCODE_E2E_SESSION_COOKIE is required" >&2
+ exit 2
+fi
+
+base_url="${base_url%/}"
+
+check_get() {
+ local path="$1"
+ local status
+
+ if ! status="$(curl \
+ --silent \
+ --show-error \
+ --request GET \
+ --connect-timeout 10 \
+ --max-time 30 \
+ --proto '=http,https' \
+ --header "Cookie: ${session_cookie}" \
+ --output /dev/null \
+ --write-out '%{http_code}' \
+ "${base_url}${path}")"; then
+ echo "GET ${path} failed" >&2
+ return 1
+ fi
+
+ if [[ "${status}" != 2?? ]]; then
+ echo "GET ${path} returned HTTP ${status}, expected 2xx" >&2
+ return 1
+ fi
+
+ echo "ok GET ${path} (${status})"
+}
+
+check_get "/healthz"
+check_get "/api/v1/session"
+check_get "/api/openapi.yaml"
diff --git a/sql/queries/analysis.sql b/sql/queries/analysis.sql
new file mode 100644
index 0000000..589b224
--- /dev/null
+++ b/sql/queries/analysis.sql
@@ -0,0 +1,139 @@
+-- name: CreateAnalysisRun :one
+INSERT INTO analysis_runs (
+ id, workspace_id, repository_id, snapshot_id, commit_sha, requested_by,
+ dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, idempotency_key, attempt, rerun_of, status
+)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'queued')
+ON CONFLICT (workspace_id, idempotency_key, attempt) DO NOTHING
+RETURNING *;
+
+-- name: LockAnalysisSnapshot :one
+SELECT snapshot.source_state
+FROM commit_snapshots AS snapshot
+JOIN repositories AS repository ON repository.id = snapshot.repository_id
+WHERE snapshot.id = $1
+ AND snapshot.repository_id = $2
+ AND repository.workspace_id = $3
+FOR UPDATE OF snapshot;
+
+-- name: GetAnalysisRunByAttempt :one
+SELECT * FROM analysis_runs
+WHERE workspace_id=$1 AND idempotency_key=$2 AND attempt=$3;
+
+-- name: GetAnalysisRun :one
+SELECT * FROM analysis_runs WHERE id=$1 AND workspace_id=$2;
+
+-- name: ListAnalysisRuns :many
+SELECT * FROM analysis_runs
+WHERE repository_id=sqlc.arg('repository_id')
+ AND workspace_id=sqlc.arg('workspace_id')
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: GetAnalysisRunWork :one
+SELECT * FROM analysis_runs WHERE id=$1;
+
+-- name: StartAnalysisRun :one
+UPDATE analysis_runs SET status='running', stage='authorize', started_at=now()
+WHERE id=$1 AND status='queued' RETURNING *;
+
+-- name: SetAnalysisRunStage :execrows
+UPDATE analysis_runs SET stage=$2
+WHERE id=$1 AND status='running';
+
+-- name: FinishAnalysisRun :one
+UPDATE analysis_runs
+SET status='succeeded', report_id=$2, failed_stage=NULL, error_code=NULL,
+ error_message=NULL, retryable=false, stage='complete', finished_at=$3
+WHERE id=$1 AND status='running' RETURNING *;
+
+-- name: FailAnalysisRun :one
+UPDATE analysis_runs
+SET status='failed', stage=$2, failed_stage=$2, error_code=$3, error_message=$4,
+ retryable=$5, finished_at=now()
+WHERE id=$1 AND status IN ('queued','running') RETURNING *;
+
+-- name: CancelAnalysisRun :one
+UPDATE analysis_runs SET status='cancelled', stage='cancelled', retryable=true, finished_at=now()
+WHERE id=$1 AND workspace_id=$2 AND status IN ('queued','running')
+RETURNING workflow_run_id;
+
+-- name: SetAnalysisWorkflowID :exec
+UPDATE analysis_runs SET workflow_run_id=$2 WHERE id=$1;
+
+-- name: CreateAnalysisReport :one
+INSERT INTO analysis_reports (
+ id, analysis_run_id, workspace_id, repository_id, snapshot_id, commit_sha,
+ source_ref, commit_author_name, commit_authored_at, commit_title,
+ dimension_key, profile_id, profile_version, profile_snapshot, analyzer_version, execution_environment,
+ started_at, finished_at, duration_ms, result, raw_artifact
+) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
+RETURNING *;
+
+-- name: AnalysisClock :one
+SELECT clock_timestamp()::timestamptz AS current_time;
+
+-- name: GetAnalysisReport :one
+SELECT * FROM analysis_reports WHERE id=$1 AND workspace_id=$2;
+
+-- name: GetAnalysisReportByRun :one
+SELECT * FROM analysis_reports WHERE analysis_run_id=$1;
+
+-- name: ListAnalysisProfiles :many
+SELECT p.*, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at
+FROM analysis_profiles p
+JOIN analysis_profile_versions v
+ ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version
+WHERE p.workspace_id=sqlc.arg('workspace_id') AND p.archived_at IS NULL
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (p.created_at, p.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY p.created_at DESC, p.id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: GetAnalysisProfile :one
+SELECT p.*, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at
+FROM analysis_profiles p
+JOIN analysis_profile_versions v
+ ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version
+WHERE p.id=$1 AND p.workspace_id=$2 AND p.archived_at IS NULL;
+
+-- name: GetDefaultAnalysisProfile :one
+SELECT p.*, v.id AS version_id, v.dimension_key, v.definition, v.created_at AS version_created_at
+FROM analysis_profiles p
+JOIN analysis_profile_versions v
+ ON v.workspace_id=p.workspace_id AND v.profile_id=p.id AND v.version=p.current_version
+WHERE p.workspace_id=$1 AND p.archived_at IS NULL
+ORDER BY p.created_at, p.id
+LIMIT 1;
+
+-- name: CreateAnalysisProfile :one
+INSERT INTO analysis_profiles (id, workspace_id, name, current_version, created_by)
+VALUES ($1,$2,$3,1,$4) RETURNING *;
+
+-- name: CreateAnalysisProfileVersion :one
+INSERT INTO analysis_profile_versions (id, workspace_id, profile_id, version, dimension_key, definition, created_by)
+VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *;
+
+-- name: AdvanceAnalysisProfile :one
+UPDATE analysis_profiles
+SET name=$3, current_version=current_version+1, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND current_version=$4 AND archived_at IS NULL
+RETURNING *;
+
+-- name: ArchiveAnalysisProfile :execrows
+UPDATE analysis_profiles SET archived_at=now(), updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL;
+
+-- name: LockActiveAnalysisProfiles :many
+SELECT id FROM analysis_profiles
+WHERE workspace_id=$1 AND archived_at IS NULL
+ORDER BY id
+FOR UPDATE;
+-- name: CountActiveAnalysisRuns :one
+SELECT count(*) FROM analysis_runs WHERE workspace_id=$1 AND status IN ('queued','running');
diff --git a/sql/queries/audit.sql b/sql/queries/audit.sql
new file mode 100644
index 0000000..7fba724
--- /dev/null
+++ b/sql/queries/audit.sql
@@ -0,0 +1,18 @@
+-- name: CreateAuditEvent :exec
+INSERT INTO audit_events (
+ id,
+ workspace_id,
+ actor_user_id,
+ action,
+ resource_type,
+ resource_id,
+ metadata
+) VALUES (
+ sqlc.arg(id),
+ sqlc.narg(workspace_id),
+ sqlc.narg(actor_user_id),
+ sqlc.arg(action),
+ sqlc.arg(resource_type),
+ sqlc.narg(resource_id),
+ sqlc.arg(metadata)
+);
diff --git a/sql/queries/channel_commands.sql b/sql/queries/channel_commands.sql
new file mode 100644
index 0000000..fdb0899
--- /dev/null
+++ b/sql/queries/channel_commands.sql
@@ -0,0 +1,68 @@
+-- name: GetChannelExternalIdentity :one
+SELECT user_id FROM channel_external_identities
+WHERE channel_id=$1 AND sender_canonical_id=$2;
+
+-- name: CheckActiveChannelVersion :one
+SELECT config_version FROM channels
+WHERE id=$1 AND workspace_id=$2 AND enabled AND config_version=$3;
+
+-- name: CreateChannelIdentityLink :one
+INSERT INTO channel_identity_links (
+ id, workspace_id, channel_id, channel_version, sender_canonical_id, token_hash, expires_at
+)
+SELECT sqlc.arg('id'), sqlc.arg('workspace_id'), sqlc.arg('channel_id'),
+ sqlc.arg('channel_version'), sqlc.arg('sender_canonical_id'),
+ sqlc.arg('token_hash'), sqlc.arg('expires_at')
+FROM channels
+WHERE id=sqlc.arg('channel_id')
+ AND workspace_id=sqlc.arg('workspace_id')
+ AND enabled
+ AND config_version=sqlc.arg('channel_version')
+RETURNING *;
+
+-- name: GetChannelIdentityLink :one
+SELECT l.* FROM channel_identity_links l
+JOIN channels c ON c.id=l.channel_id
+WHERE l.token_hash=$1
+ AND l.consumed_at IS NULL
+ AND l.expires_at > now()
+ AND c.enabled
+ AND c.config_version=l.channel_version;
+
+-- name: ConsumeChannelIdentityLink :execrows
+UPDATE channel_identity_links l SET consumed_at=now()
+FROM channels c
+WHERE l.id=$1
+ AND l.consumed_at IS NULL
+ AND l.expires_at > now()
+ AND c.id=l.channel_id
+ AND c.enabled
+ AND c.config_version=l.channel_version;
+
+-- name: UpsertChannelExternalIdentity :exec
+INSERT INTO channel_external_identities (
+ id, workspace_id, channel_id, sender_canonical_id, user_id
+) VALUES ($1,$2,$3,$4,$5)
+ON CONFLICT (channel_id, sender_canonical_id) DO UPDATE
+SET user_id=excluded.user_id;
+
+-- name: GetConversationIDByExternal :one
+SELECT conversation.id FROM conversations AS conversation
+JOIN channels AS channel ON channel.id=conversation.channel_id
+WHERE conversation.channel_id=sqlc.arg('channel_id')
+ AND conversation.external_id=sqlc.arg('external_id')
+ AND channel.workspace_id=sqlc.arg('workspace_id')
+ AND channel.enabled
+ AND channel.config_version=sqlc.arg('channel_version');
+
+-- name: GetConversationBinding :one
+SELECT repository_id FROM conversation_bindings WHERE conversation_id=$1;
+
+-- name: UpsertConversationBinding :exec
+INSERT INTO conversation_bindings (conversation_id, workspace_id, repository_id, bound_by)
+VALUES ($1,$2,$3,$4)
+ON CONFLICT (conversation_id) DO UPDATE
+SET repository_id=excluded.repository_id, bound_by=excluded.bound_by, updated_at=now();
+
+-- name: DeleteConversationBinding :execrows
+DELETE FROM conversation_bindings WHERE conversation_id=$1;
diff --git a/sql/queries/channels.sql b/sql/queries/channels.sql
index fd7b587..92c13c4 100644
--- a/sql/queries/channels.sql
+++ b/sql/queries/channels.sql
@@ -1,142 +1,124 @@
--- name: CreateChannelInstance :one
-INSERT INTO channel_instances (id, workspace_id, type, name, enabled, config, secret_ref)
-VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(type), sqlc.arg(name), false,
- sqlc.arg(config), sqlc.narg(secret_ref))
-RETURNING *;
-
--- name: ListChannelInstances :many
-SELECT * FROM channel_instances
-WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
-ORDER BY created_at DESC, id DESC;
-
--- name: GetChannelInstance :one
-SELECT * FROM channel_instances
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
-
--- name: ListEnabledChannelInstances :many
-SELECT * FROM channel_instances
-WHERE enabled = true AND deleted_at IS NULL
-ORDER BY id;
+-- name: ListChannels :many
+SELECT * FROM channels
+WHERE workspace_id=sqlc.arg('workspace_id')
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: CreateChannel :one
+INSERT INTO channels (id, workspace_id, type, name, enabled, secret_ciphertext, secret_nonce, key_version, config)
+VALUES ($1,$2,$3,$4,false,$5,$6,$7,$8) RETURNING *;
+
+-- name: GetChannel :one
+SELECT * FROM channels WHERE id=$1 AND workspace_id=$2;
+
+-- name: ListChannelSubscriptions :many
+SELECT channel_id, event_type FROM channel_subscriptions
+WHERE workspace_id=$1
+ORDER BY channel_id, event_type;
+
+-- name: ListSubscribedChannels :many
+SELECT c.* FROM channels c
+JOIN channel_subscriptions subscription ON subscription.channel_id=c.id AND subscription.workspace_id=c.workspace_id
+WHERE c.workspace_id=$1 AND c.enabled AND subscription.event_type=$2
+ORDER BY c.created_at;
+
+-- name: DeleteChannelSubscriptions :exec
+DELETE FROM channel_subscriptions WHERE workspace_id=$1 AND channel_id=$2;
+
+-- name: CreateChannelSubscription :exec
+INSERT INTO channel_subscriptions (id, workspace_id, channel_id, event_type)
+VALUES ($1,$2,$3,$4);
+
+-- name: ListRuntimeChannels :many
+SELECT * FROM channels
+WHERE enabled AND secret_ciphertext IS NOT NULL AND secret_nonce IS NOT NULL AND key_version IS NOT NULL
+ORDER BY created_at;
+
+-- name: LockChannelRuntime :one
+SELECT config_version FROM channels
+WHERE id=$1 AND workspace_id=$2 AND enabled
+FOR KEY SHARE;
+
+-- name: MarkChannelRuntimeConnected :execrows
+UPDATE channels SET runtime_status='connected', last_connected_at=now(), last_error_message=NULL, updated_at=now()
+WHERE id=$1 AND enabled AND config_version=$2;
+
+-- name: MarkChannelRuntimeError :execrows
+UPDATE channels SET runtime_status='error', last_error_message=$3, updated_at=now()
+WHERE id=$1 AND enabled AND config_version=$2;
+
+-- name: UpdateChannel :one
+UPDATE channels SET name=$3, config=$4, config_version=config_version+1,
+ runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END,
+ last_error_message=NULL, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND config_version=$5 RETURNING *;
+
+-- name: RotateChannelCredential :one
+UPDATE channels SET secret_ciphertext=$3, secret_nonce=$4, key_version=$5,
+ config_version=config_version+1,
+ runtime_status=CASE WHEN enabled THEN 'starting' ELSE 'disabled' END,
+ last_error_message=NULL, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND config_version=$6 RETURNING *;
-- name: SetChannelEnabled :one
-UPDATE channel_instances SET enabled = sqlc.arg(enabled), config_version = config_version + 1,
- updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
-RETURNING *;
-
--- name: SoftDeleteChannel :one
-UPDATE channel_instances
-SET enabled = false, secret_ref = NULL, deleted_at = now(), config_version = config_version + 1, updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
-RETURNING *;
-
--- name: UpdateChannelInstance :one
-UPDATE channel_instances SET name = sqlc.arg(name), config = sqlc.arg(config),
- secret_ref = sqlc.narg(secret_ref), config_version = config_version + 1, updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id)
- AND config_version = sqlc.arg(expected_version) AND deleted_at IS NULL
-RETURNING *;
-
--- name: AcquireChannelLease :one
-INSERT INTO channel_leases (channel_instance_id, owner, lease_until, fencing_token)
-VALUES (sqlc.arg(channel_instance_id), sqlc.arg(owner), sqlc.arg(lease_until), 1)
-ON CONFLICT (channel_instance_id) DO UPDATE
-SET owner = EXCLUDED.owner, lease_until = EXCLUDED.lease_until,
- fencing_token = CASE WHEN channel_leases.owner = EXCLUDED.owner
- THEN channel_leases.fencing_token ELSE channel_leases.fencing_token + 1 END,
- updated_at = now()
-WHERE channel_leases.owner = EXCLUDED.owner OR channel_leases.lease_until < now()
-RETURNING *;
-
--- name: RenewChannelLease :execrows
-UPDATE channel_leases SET lease_until = sqlc.arg(lease_until), updated_at = now()
-WHERE channel_instance_id = sqlc.arg(channel_instance_id) AND owner = sqlc.arg(owner)
- AND fencing_token = sqlc.arg(fencing_token);
-
--- name: ReleaseChannelLease :exec
-DELETE FROM channel_leases
-WHERE channel_instance_id = sqlc.arg(channel_instance_id) AND owner = sqlc.arg(owner)
- AND fencing_token = sqlc.arg(fencing_token);
-
--- name: SetChannelRuntimeStatus :execrows
-INSERT INTO channel_runtime_status (
- channel_instance_id, state, backend_instance_id, fencing_token,
- last_connected_at, last_error_code, last_error_message
-) SELECT
- lease.channel_instance_id, sqlc.arg(state), sqlc.arg(backend_instance_id),
- sqlc.arg(fencing_token), sqlc.narg(last_connected_at), sqlc.arg(last_error_code),
- sqlc.arg(last_error_message)
-FROM channel_leases AS lease
-WHERE lease.channel_instance_id = sqlc.arg(channel_instance_id)
- AND lease.owner = sqlc.arg(backend_instance_id)
- AND lease.fencing_token = sqlc.arg(fencing_token)
- AND lease.lease_until > now()
-ON CONFLICT (channel_instance_id) DO UPDATE SET
- state = EXCLUDED.state, backend_instance_id = EXCLUDED.backend_instance_id,
- fencing_token = EXCLUDED.fencing_token,
- last_connected_at = COALESCE(EXCLUDED.last_connected_at, channel_runtime_status.last_connected_at),
- last_error_code = EXCLUDED.last_error_code,
- last_error_message = EXCLUDED.last_error_message, updated_at = now()
-WHERE channel_runtime_status.fencing_token <= EXCLUDED.fencing_token;
-
--- name: GetChannelRuntimeStatus :one
-SELECT * FROM channel_runtime_status WHERE channel_instance_id = sqlc.arg(channel_instance_id);
-
--- name: InsertInboxEvent :one
-INSERT INTO inbox_events (channel_instance_id, external_event_id, payload_hash)
-VALUES (sqlc.arg(channel_instance_id), sqlc.arg(external_event_id), sqlc.arg(payload_hash))
-ON CONFLICT DO NOTHING
-RETURNING external_event_id;
-
--- name: UpsertIMConversation :one
-INSERT INTO im_conversations (id, workspace_id, channel_instance_id, external_id, type, title)
-VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_instance_id),
- sqlc.arg(external_id), sqlc.arg(type), sqlc.arg(title))
-ON CONFLICT (channel_instance_id, external_id) DO UPDATE
-SET type = EXCLUDED.type, title = CASE WHEN EXCLUDED.title = '' THEN im_conversations.title ELSE EXCLUDED.title END,
- updated_at = now()
-RETURNING *;
-
--- name: UpsertIMSender :one
-INSERT INTO im_senders (id, workspace_id, channel_type, canonical_id, display_name)
-VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_type),
- sqlc.arg(canonical_id), sqlc.arg(display_name))
-ON CONFLICT (workspace_id, canonical_id) DO UPDATE
-SET display_name = CASE WHEN EXCLUDED.display_name = '' THEN im_senders.display_name ELSE EXCLUDED.display_name END,
- updated_at = now()
-RETURNING *;
-
--- name: InsertIMMessage :one
-INSERT INTO im_messages (
- id, workspace_id, channel_instance_id, conversation_id, sender_id,
- external_message_id, content, occurred_at
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(channel_instance_id),
- sqlc.arg(conversation_id), sqlc.arg(sender_id), sqlc.arg(external_message_id),
- sqlc.arg(content), sqlc.arg(occurred_at)
+UPDATE channels SET enabled=$3, runtime_status=CASE WHEN $3 THEN 'starting' ELSE 'disabled' END,
+ last_error_message=CASE WHEN $3 THEN NULL ELSE last_error_message END, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 RETURNING *;
+
+-- name: DeleteChannel :execrows
+DELETE FROM channels WHERE id=$1 AND workspace_id=$2;
+
+-- name: UpsertConversation :one
+INSERT INTO conversations (id, workspace_id, channel_id, external_id, type, title)
+VALUES ($1,$2,$3,$4,$5,$6)
+ON CONFLICT (channel_id, external_id) DO UPDATE
+SET type=excluded.type, title=excluded.title
+RETURNING id;
+
+-- name: InsertInboundMessage :execrows
+INSERT INTO messages (
+ id, workspace_id, channel_id, conversation_id, external_id,
+ sender_canonical_id, sender_display_name, content, occurred_at
)
-ON CONFLICT (channel_instance_id, external_message_id) DO NOTHING
-RETURNING *;
-
--- name: ListIMMessages :many
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
+ON CONFLICT (channel_id, external_id) DO NOTHING;
+
+-- name: ListConversations :many
+SELECT c.id, c.workspace_id, c.channel_id, c.external_id, c.type, c.title, c.created_at,
+ ch.name AS channel_name, ch.type AS channel_type
+FROM conversations c JOIN channels ch ON ch.id=c.channel_id
+WHERE c.workspace_id=sqlc.arg('workspace_id')
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (c.created_at, c.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY c.created_at DESC, c.id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: ListMessages :many
SELECT m.*, c.external_id AS conversation_external_id, c.type AS conversation_type,
- s.canonical_id AS sender_canonical_id, s.display_name AS sender_display_name,
- ci.name AS channel_name, ci.type AS channel_type
-FROM im_messages m
-JOIN im_conversations c ON c.id = m.conversation_id AND c.workspace_id = m.workspace_id
-JOIN im_senders s ON s.id = m.sender_id AND s.workspace_id = m.workspace_id
-JOIN channel_instances ci ON ci.id = m.channel_instance_id AND ci.workspace_id = m.workspace_id
-WHERE m.workspace_id = sqlc.arg(workspace_id)
- AND (sqlc.narg(channel_instance_id)::uuid IS NULL OR m.channel_instance_id = sqlc.narg(channel_instance_id))
- AND (sqlc.narg(conversation_id)::uuid IS NULL OR m.conversation_id = sqlc.narg(conversation_id))
- AND (sqlc.narg(before_time)::timestamptz IS NULL OR (m.occurred_at, m.id) < (sqlc.narg(before_time), sqlc.narg(before_id)::uuid))
+ ch.name AS channel_name, ch.type AS channel_type
+FROM messages m
+JOIN conversations c ON c.id=m.conversation_id
+JOIN channels ch ON ch.id=m.channel_id
+WHERE m.workspace_id=sqlc.arg('workspace_id')
+ AND (sqlc.narg('channel_id')::uuid IS NULL OR m.channel_id=sqlc.narg('channel_id'))
+ AND (sqlc.narg('conversation_id')::uuid IS NULL OR m.conversation_id=sqlc.narg('conversation_id'))
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (m.occurred_at, m.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
ORDER BY m.occurred_at DESC, m.id DESC
-LIMIT sqlc.arg(page_size);
-
--- name: ListIMConversations :many
-SELECT c.*, ci.name AS channel_name, ci.type AS channel_type
-FROM im_conversations c
-JOIN channel_instances ci ON ci.id = c.channel_instance_id AND ci.workspace_id = c.workspace_id
-WHERE c.workspace_id = sqlc.arg(workspace_id)
-ORDER BY c.updated_at DESC, c.id DESC
-LIMIT sqlc.arg(page_size);
+LIMIT sqlc.arg('limit');
+
+-- name: GetWorkspaceOverview :one
+SELECT
+ (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted') AS repository_count,
+ (SELECT count(*) FROM repositories rr WHERE rr.workspace_id=$1 AND rr.status <> 'deleted' AND rr.current_snapshot_id IS NULL) AS repository_without_code_count,
+ (SELECT count(*) FROM repository_operations o JOIN repositories r ON r.id=o.repository_id WHERE r.workspace_id=$1 AND o.status='failed' AND o.created_at > now()-interval '7 days') AS recent_failed_sync_count,
+ (SELECT count(*) FROM analysis_runs ar WHERE ar.workspace_id=$1 AND ar.status IN ('queued','running')) AS active_analysis_count,
+ (SELECT count(*) FROM channels cc WHERE cc.workspace_id=$1 AND cc.enabled) AS active_channel_count;
diff --git a/sql/queries/identity.sql b/sql/queries/identity.sql
index 4ada781..a895a99 100644
--- a/sql/queries/identity.sql
+++ b/sql/queries/identity.sql
@@ -1,132 +1,103 @@
--- name: UpsertUser :one
-INSERT INTO users (
- id, issuer, external_subject, username, email, display_name, last_seen_at
-) VALUES (
- sqlc.arg(id), sqlc.arg(issuer), sqlc.arg(external_subject),
- sqlc.arg(username), sqlc.arg(email), sqlc.arg(display_name), now()
-)
-ON CONFLICT (issuer, external_subject) DO UPDATE SET
- username = EXCLUDED.username,
- email = EXCLUDED.email,
- display_name = EXCLUDED.display_name,
- last_seen_at = now(),
- updated_at = now()
+-- name: GetUserByIdentity :one
+SELECT u.* FROM users u
+JOIN oauth_identities oi ON oi.user_id = u.id
+WHERE oi.issuer = $1 AND oi.subject = $2;
+
+-- name: CreateUser :one
+INSERT INTO users (id, display_name, username, email, status)
+VALUES ($1, $2, $3, $4, $5)
RETURNING *;
--- name: GetUserByExternalIdentity :one
-SELECT * FROM users
-WHERE issuer = sqlc.arg(issuer) AND external_subject = sqlc.arg(external_subject);
+-- name: CreateOAuthIdentity :exec
+INSERT INTO oauth_identities (issuer, subject, user_id)
+VALUES ($1, $2, $3);
--- name: GetUserForUpdate :one
-SELECT * FROM users
-WHERE id = sqlc.arg(id)
-FOR UPDATE;
+-- name: UpdateUserProfile :one
+UPDATE users SET display_name = $2, username = $3, email = $4, updated_at = now()
+WHERE id = $1 RETURNING *;
-- name: ActivateUser :one
-UPDATE users SET
- status = 'active',
- terms_version = sqlc.arg(terms_version),
- privacy_version = sqlc.arg(privacy_version),
- activated_at = COALESCE(activated_at, now()),
- agreements_accepted_at = now(),
- updated_at = now()
-WHERE id = sqlc.arg(id) AND status IN ('pending', 'active')
+UPDATE users
+SET status = 'active', accepted_terms_at = now(), accepted_privacy_at = now(), updated_at = now()
+WHERE id = $1 AND status = 'pending'
RETURNING *;
+-- name: GetUser :one
+SELECT * FROM users WHERE id = $1;
+
-- name: CreateWorkspace :one
-INSERT INTO workspaces (id, name, slug, kind, created_by)
-VALUES (sqlc.arg(id), sqlc.arg(name), sqlc.arg(slug), sqlc.arg(kind), sqlc.arg(created_by))
+INSERT INTO workspaces (id, name, slug, created_by)
+VALUES ($1, $2, $3, $4)
RETURNING *;
--- name: AddWorkspaceMember :one
+-- name: CreateRetentionCleanupSchedule :exec
+INSERT INTO retention_cleanup_schedules (workspace_id)
+VALUES ($1);
+
+-- name: AddWorkspaceMember :exec
INSERT INTO workspace_members (workspace_id, user_id, role)
-VALUES (sqlc.arg(workspace_id), sqlc.arg(user_id), sqlc.arg(role))
-ON CONFLICT (workspace_id, user_id) DO NOTHING
-RETURNING *;
+VALUES ($1, $2, $3)
+ON CONFLICT (workspace_id, user_id) DO NOTHING;
--- name: GetPersonalWorkspaceForUser :one
-SELECT w.*
-FROM workspaces w
-JOIN workspace_members wm ON wm.workspace_id = w.id
-WHERE wm.user_id = sqlc.arg(user_id)
- AND w.kind = 'personal'
- AND w.deleted_at IS NULL
-ORDER BY w.created_at
-LIMIT 1;
-
--- name: ListWorkspacesForUser :many
-SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role
-FROM workspaces w
+-- name: GetWorkspaceMembership :one
+SELECT w.*, wm.role FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
-WHERE wm.user_id = sqlc.arg(user_id)
- AND w.deleted_at IS NULL
-ORDER BY w.created_at, w.id;
+JOIN users u ON u.id = wm.user_id AND u.status = 'active'
+WHERE w.id = $1 AND wm.user_id = $2;
--- name: GetWorkspaceMembership :one
-SELECT w.id, w.name, w.slug, w.kind, w.created_by, w.created_at, w.updated_at, wm.role
-FROM workspaces w
+-- name: ListUserWorkspaces :many
+SELECT w.*, wm.role FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
-WHERE w.id = sqlc.arg(workspace_id)
- AND wm.user_id = sqlc.arg(user_id)
- AND w.deleted_at IS NULL;
+WHERE wm.user_id = $1
+ORDER BY w.created_at;
-- name: UpdateWorkspace :one
-UPDATE workspaces SET name = sqlc.arg(name), updated_at = now()
-WHERE id = sqlc.arg(id) AND deleted_at IS NULL
+UPDATE workspaces
+SET name = COALESCE(sqlc.narg('name')::text, name),
+ report_retention_days = COALESCE(sqlc.narg('report_retention_days')::integer, report_retention_days),
+ updated_at = clock_timestamp()
+WHERE id = sqlc.arg('id')
RETURNING *;
-- name: ListWorkspaceMembers :many
-SELECT wm.workspace_id, wm.user_id, wm.role, wm.created_at, wm.updated_at,
- u.username, u.email, u.display_name
-FROM workspace_members AS wm
-JOIN users AS u ON u.id = wm.user_id
-WHERE wm.workspace_id = sqlc.arg(workspace_id)
-ORDER BY wm.created_at, wm.user_id;
-
--- name: UpsertWorkspaceMember :exec
-INSERT INTO workspace_members (workspace_id, user_id, role)
-VALUES (sqlc.arg(workspace_id), sqlc.arg(user_id), sqlc.arg(role))
-ON CONFLICT (workspace_id, user_id) DO UPDATE
-SET role = EXCLUDED.role, updated_at = now();
-
--- name: DeleteWorkspaceMember :execrows
-DELETE FROM workspace_members
-WHERE workspace_id = sqlc.arg(workspace_id) AND user_id = sqlc.arg(user_id);
+SELECT u.id, u.username, u.email, u.display_name, wm.role, wm.created_at
+FROM workspace_members wm JOIN users u ON u.id = wm.user_id
+WHERE wm.workspace_id = sqlc.arg('workspace_id')
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (wm.created_at, wm.user_id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY wm.created_at DESC, wm.user_id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: RemoveWorkspaceMember :execrows
+DELETE FROM workspace_members WHERE workspace_id = $1 AND user_id = $2 AND role <> 'owner';
-- name: CreateWorkspaceInvitation :one
-INSERT INTO workspace_invitations (
- id, workspace_id, email_normalized, role, token_hash, invited_by, expires_at
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(email_normalized), sqlc.arg(role),
- sqlc.arg(token_hash), sqlc.arg(invited_by), sqlc.arg(expires_at)
-)
+INSERT INTO workspace_invitations (id, workspace_id, email, token_hash, created_by, expires_at)
+VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListWorkspaceInvitations :many
SELECT * FROM workspace_invitations
-WHERE workspace_id = sqlc.arg(workspace_id)
-ORDER BY created_at DESC, id DESC;
+WHERE workspace_id = sqlc.arg('workspace_id')
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: RevokeWorkspaceInvitation :execrows
+UPDATE workspace_invitations SET revoked_at = now()
+WHERE id = $1 AND workspace_id = $2 AND accepted_at IS NULL AND revoked_at IS NULL;
--- name: GetWorkspaceInvitationForUpdate :one
+-- name: GetInvitationByTokenHash :one
SELECT * FROM workspace_invitations
-WHERE token_hash = sqlc.arg(token_hash)
-FOR UPDATE;
+WHERE token_hash = $1 AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > now();
-- name: AcceptWorkspaceInvitation :execrows
-UPDATE workspace_invitations SET
- accepted_at = now(),
- accepted_by = sqlc.arg(accepted_by),
- updated_at = now()
-WHERE id = sqlc.arg(id) AND accepted_at IS NULL AND revoked_at IS NULL;
-
--- name: RevokeWorkspaceInvitation :execrows
-UPDATE workspace_invitations SET revoked_at = now(), updated_at = now()
-WHERE id = sqlc.arg(id) AND accepted_at IS NULL AND revoked_at IS NULL;
-
--- name: AppendAccountAudit :exec
-INSERT INTO account_audit_logs (
- id, user_id, actor_user_id, action, result, provider, request_id, metadata, occurred_at
-) VALUES (
- sqlc.arg(id), sqlc.arg(user_id), sqlc.narg(actor_user_id), sqlc.arg(action),
- sqlc.arg(result), sqlc.arg(provider), sqlc.arg(request_id), sqlc.arg(metadata), sqlc.arg(occurred_at)
-);
+UPDATE workspace_invitations SET accepted_at = now()
+WHERE id = $1 AND accepted_at IS NULL AND revoked_at IS NULL;
+-- name: LockWorkspaceQuota :one
+SELECT id FROM workspaces WHERE id=$1 FOR UPDATE;
diff --git a/sql/queries/jobs.sql b/sql/queries/jobs.sql
deleted file mode 100644
index 0debe57..0000000
--- a/sql/queries/jobs.sql
+++ /dev/null
@@ -1,113 +0,0 @@
--- name: CreateJob :one
-INSERT INTO jobs (
- id, workspace_id, type, payload, status, idempotency_key,
- max_attempts, run_after, trace_context
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(type), sqlc.arg(payload), 'queued',
- sqlc.arg(idempotency_key), sqlc.arg(max_attempts), sqlc.arg(run_after), sqlc.arg(trace_context)
-)
-RETURNING *;
-
--- name: GetJob :one
-SELECT * FROM jobs WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id);
-
--- name: CancelJob :one
-UPDATE jobs
-SET status = 'cancelled', lease_owner = NULL, lease_until = NULL,
- last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user',
- finished_at = now(), updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id)
- AND status IN ('queued', 'running')
-RETURNING *;
-
--- name: CancelActiveRepositorySyncJob :one
-UPDATE jobs
-SET status = 'cancelled', lease_owner = NULL, lease_until = NULL,
- last_error_code = 'job.cancelled', last_error_message = 'Cancelled by user',
- finished_at = now(), updated_at = now()
-WHERE workspace_id = sqlc.arg(workspace_id) AND type = 'repository.sync'
- AND payload->>'repositoryId' = sqlc.arg(repository_id)::text
- AND status IN ('queued', 'running')
-RETURNING *;
-
--- name: ListJobQueueDepths :many
-SELECT type, status, count(*)::bigint AS depth
-FROM jobs
-WHERE type = ANY(sqlc.arg(types)::text[]) AND status IN ('queued', 'running')
-GROUP BY type, status
-ORDER BY type, status;
-
--- name: LockCurrentJob :one
-SELECT id FROM jobs
-WHERE id = sqlc.arg(id) AND status = 'running'
- AND lease_owner = sqlc.arg(lease_owner)
- AND fencing_token = sqlc.arg(fencing_token)
- AND lease_until > now()
-FOR UPDATE;
-
--- name: ClaimJob :one
-WITH candidate AS (
- SELECT id FROM jobs
- WHERE type = ANY(sqlc.arg(types)::text[])
- AND attempt < max_attempts
- AND ((status = 'queued' AND run_after <= now())
- OR (status = 'running' AND lease_until < now()))
- ORDER BY run_after, created_at
- FOR UPDATE SKIP LOCKED
- LIMIT 1
-)
-UPDATE jobs AS job
-SET status = 'running', lease_owner = sqlc.arg(lease_owner),
- lease_until = sqlc.arg(lease_until), fencing_token = fencing_token + 1,
- attempt = attempt + 1, started_at = COALESCE(started_at, now()), updated_at = now()
-FROM candidate
-WHERE job.id = candidate.id
-RETURNING job.*;
-
--- name: RenewJobLease :execrows
-UPDATE jobs SET lease_until = sqlc.arg(lease_until), updated_at = now()
-WHERE id = sqlc.arg(id) AND status = 'running'
- AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token);
-
--- name: CompleteJob :execrows
-UPDATE jobs SET status = 'succeeded', lease_owner = NULL, lease_until = NULL,
- finished_at = now(), updated_at = now()
-WHERE id = sqlc.arg(id) AND status = 'running'
- AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token);
-
--- name: RetryJob :execrows
-UPDATE jobs SET status = 'queued', lease_owner = NULL, lease_until = NULL,
- run_after = sqlc.arg(run_after), last_error_code = sqlc.arg(error_code),
- last_error_message = sqlc.arg(error_message), updated_at = now()
-WHERE id = sqlc.arg(id) AND status = 'running' AND attempt < max_attempts
- AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token);
-
--- name: FailJob :execrows
-UPDATE jobs SET status = 'failed', lease_owner = NULL, lease_until = NULL,
- last_error_code = sqlc.arg(error_code), last_error_message = sqlc.arg(error_message),
- finished_at = now(), updated_at = now()
-WHERE id = sqlc.arg(id) AND status = 'running'
- AND lease_owner = sqlc.arg(lease_owner) AND fencing_token = sqlc.arg(fencing_token);
-
--- name: AppendOutboxEvent :exec
-INSERT INTO outbox_events (
- id, workspace_id, aggregate, aggregate_id, event_type, payload, trace_context
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(aggregate), sqlc.arg(aggregate_id),
- sqlc.arg(event_type), sqlc.arg(payload), sqlc.arg(trace_context)
-);
-
--- name: AppendAuditLog :exec
-INSERT INTO audit_logs (
- id, workspace_id, actor_user_id, action, resource_type, resource_id, result, metadata
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.narg(actor_user_id), sqlc.arg(action),
- sqlc.arg(resource_type), sqlc.narg(resource_id), sqlc.arg(result), sqlc.arg(metadata)
-);
-
--- name: ListAuditLogs :many
-SELECT * FROM audit_logs
-WHERE workspace_id = sqlc.arg(workspace_id)
- AND (sqlc.narg(before_time)::timestamptz IS NULL OR (occurred_at, id) < (sqlc.narg(before_time), sqlc.narg(before_id)::uuid))
-ORDER BY occurred_at DESC, id DESC
-LIMIT sqlc.arg(page_size);
diff --git a/sql/queries/notifications.sql b/sql/queries/notifications.sql
new file mode 100644
index 0000000..dfb0c03
--- /dev/null
+++ b/sql/queries/notifications.sql
@@ -0,0 +1,44 @@
+-- name: CreateNotification :one
+INSERT INTO notifications (id, workspace_id, analysis_run_id, channel_id, event_type, status)
+VALUES ($1,$2,$3,$4,$5,'queued') RETURNING *;
+
+-- name: StartNotification :one
+UPDATE notifications SET status='running', started_at=now()
+WHERE id=$1 AND status='queued' RETURNING *;
+
+-- name: FinishNotification :execrows
+WITH delivered AS (
+ UPDATE notifications SET status='delivered', finished_at=now()
+ WHERE notifications.id=$1 AND notifications.status='running' RETURNING channel_id
+)
+UPDATE channels SET last_connected_at=now(), last_error_message=NULL, updated_at=now()
+FROM delivered WHERE channels.id=delivered.channel_id;
+
+-- name: FailNotification :exec
+WITH failed AS (
+ UPDATE notifications SET status='failed', error_message=$2, finished_at=now()
+ WHERE notifications.id=$1 AND notifications.status IN ('queued','running') RETURNING channel_id
+)
+UPDATE channels SET last_error_message=$2, updated_at=now()
+FROM failed WHERE channels.id=failed.channel_id;
+
+-- name: CancelNotification :exec
+UPDATE notifications SET status='cancelled', finished_at=now()
+WHERE id=$1 AND status IN ('queued','running');
+
+-- name: CancelChannelNotifications :exec
+WITH cancelled_notifications AS (
+ UPDATE notifications
+ SET status='cancelled', finished_at=now()
+ WHERE channel_id=$1 AND workspace_id=$2 AND status IN ('queued','running')
+ RETURNING id
+)
+UPDATE workflow_dispatches AS dispatch
+SET status='cancelled'
+FROM cancelled_notifications AS notification
+WHERE dispatch.aggregate_type='notification'
+ AND dispatch.aggregate_id=notification.id
+ AND dispatch.status='pending';
+
+-- name: SetNotificationWorkflowID :exec
+UPDATE notifications SET workflow_run_id=$2 WHERE id=$1;
diff --git a/sql/queries/outbox.sql b/sql/queries/outbox.sql
deleted file mode 100644
index 7c4d7f6..0000000
--- a/sql/queries/outbox.sql
+++ /dev/null
@@ -1,33 +0,0 @@
--- name: ClaimOutboxEvent :one
-WITH candidate AS (
- SELECT id
- FROM outbox_events
- WHERE published_at IS NULL AND next_attempt_at <= now()
- AND (lease_until IS NULL OR lease_until < now())
- ORDER BY next_attempt_at, created_at
- FOR UPDATE SKIP LOCKED
- LIMIT 1
-)
-UPDATE outbox_events AS event
-SET lease_owner = sqlc.arg(lease_owner), lease_until = sqlc.arg(lease_until),
- attempt = attempt + 1
-FROM candidate
-WHERE event.id = candidate.id
-RETURNING event.*;
-
--- name: CompleteOutboxEvent :execrows
-UPDATE outbox_events
-SET published_at = now(), lease_owner = NULL, lease_until = NULL,
- last_error_code = '', last_error_message = ''
-WHERE id = sqlc.arg(id) AND published_at IS NULL
- AND lease_owner = sqlc.arg(lease_owner) AND attempt = sqlc.arg(attempt);
-
--- name: RetryOutboxEvent :execrows
-UPDATE outbox_events
-SET lease_owner = NULL, lease_until = NULL, next_attempt_at = sqlc.arg(next_attempt_at),
- last_error_code = sqlc.arg(error_code), last_error_message = sqlc.arg(error_message)
-WHERE id = sqlc.arg(id) AND published_at IS NULL
- AND lease_owner = sqlc.arg(lease_owner) AND attempt = sqlc.arg(attempt);
-
--- name: CountOutboxBacklog :one
-SELECT count(*) FROM outbox_events WHERE published_at IS NULL;
diff --git a/sql/queries/providers.sql b/sql/queries/providers.sql
new file mode 100644
index 0000000..708fc44
--- /dev/null
+++ b/sql/queries/providers.sql
@@ -0,0 +1,54 @@
+-- name: ListProviderConnections :many
+SELECT id, user_id, provider_type, base_url, provider_account_id, login, display_name,
+ scopes, is_default, status, credential_version, last_validated_at, last_used_at, last_error_code,
+ created_at, updated_at
+FROM provider_connections WHERE user_id = $1 AND status <> 'revoked'
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY created_at DESC, id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: CreateProviderConnection :one
+INSERT INTO provider_connections (
+ id, user_id, provider_type, base_url, token_ciphertext, token_nonce, key_version,
+ provider_account_id, login, display_name, scopes, is_default, status, last_validated_at
+) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',now())
+RETURNING *;
+
+-- name: GetProviderConnection :one
+SELECT * FROM provider_connections WHERE id = $1 AND user_id = $2 AND status <> 'revoked';
+
+-- name: MarkProviderConnectionUsed :execrows
+UPDATE provider_connections SET last_used_at=now(), updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status='active';
+
+-- name: ReplaceProviderCredential :one
+UPDATE provider_connections
+SET token_ciphertext=$3, token_nonce=$4, key_version=$5,
+ credential_version=credential_version+1, status='active', last_error_code=NULL, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status <> 'revoked'
+RETURNING *;
+
+-- name: SetProviderConnectionValidation :one
+UPDATE provider_connections
+SET provider_account_id=$3, login=$4, display_name=$5, scopes=$6, status=$7,
+ last_validated_at=now(), last_error_code=$8, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status <> 'revoked'
+RETURNING *;
+
+-- name: ClearDefaultProviderConnections :exec
+UPDATE provider_connections SET is_default=false, updated_at=now()
+WHERE user_id=$1 AND provider_type=$2 AND status <> 'revoked';
+
+-- name: SetDefaultProviderConnection :one
+UPDATE provider_connections SET is_default=true, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status='active'
+RETURNING *;
+
+-- name: RevokeProviderConnection :execrows
+UPDATE provider_connections
+SET status='revoked', is_default=false, token_ciphertext=decode('', 'hex'),
+ token_nonce=decode('', 'hex'), credential_version=credential_version+1, updated_at=now()
+WHERE id=$1 AND user_id=$2 AND status <> 'revoked';
diff --git a/sql/queries/repositories.sql b/sql/queries/repositories.sql
index 4f0d7c8..7d48c9b 100644
--- a/sql/queries/repositories.sql
+++ b/sql/queries/repositories.sql
@@ -1,102 +1,196 @@
--- name: CreateSCMConnection :one
-INSERT INTO scm_connections (id, workspace_id, type, name, base_url, auth_type, secret_ref)
-VALUES (sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(type), sqlc.arg(name),
- sqlc.arg(base_url), sqlc.arg(auth_type), sqlc.narg(secret_ref))
+-- name: CreateRepository :one
+INSERT INTO repositories (id, workspace_id, provider_type, name, remote_url, normalized_url, configured_ref, git_path, status, created_by)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'provisioning',$9) RETURNING *;
+
+-- name: ReserveRepositorySource :one
+INSERT INTO repository_source_keys (workspace_id, normalized_url, repository_id)
+VALUES ($1,$2,$3)
+ON CONFLICT (workspace_id, normalized_url) DO UPDATE
+SET normalized_url=excluded.normalized_url
+WHERE repository_source_keys.repository_id=excluded.repository_id
+RETURNING repository_id;
+
+-- name: ReleaseRepositorySource :execrows
+DELETE FROM repository_source_keys
+WHERE workspace_id=$1 AND normalized_url=$2 AND repository_id=$3;
+
+-- name: GetRepository :one
+SELECT * FROM repositories WHERE id=$1 AND workspace_id=$2 AND status <> 'deleted';
+
+-- name: ListRepositories :many
+SELECT r.*, s.commit_sha AS current_commit_sha, s.author_name AS current_author_name,
+ s.source_ref AS current_source_ref, s.authored_at AS current_authored_at, s.title AS current_title,
+ s.source_state AS current_source_state, s.created_at AS snapshot_created_at
+FROM repositories r LEFT JOIN commit_snapshots s ON s.id=r.current_snapshot_id
+WHERE r.workspace_id=sqlc.arg('workspace_id')
+ AND r.status <> 'deleted'
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (r.created_at, r.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY r.created_at DESC, r.id DESC
+LIMIT sqlc.arg('limit');
+
+-- name: UpdateRepository :one
+UPDATE repositories SET name=$3, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 RETURNING *;
+
+-- name: StageRepositoryUpdate :one
+UPDATE repositories
+SET name=sqlc.arg('name'), provider_type=sqlc.arg('provider_type'),
+ remote_url=sqlc.arg('remote_url'), normalized_url=sqlc.arg('normalized_url'),
+ configured_ref=sqlc.arg('configured_ref'), config_version=config_version+1, status='syncing',
+ updated_at=now()
+WHERE id=sqlc.arg('id') AND workspace_id=sqlc.arg('workspace_id')
+ AND config_version=sqlc.arg('config_version')
+ AND status NOT IN ('provisioning','syncing','deleting','deleted')
RETURNING *;
--- name: ListSCMConnections :many
-SELECT * FROM scm_connections
-WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
-ORDER BY created_at DESC, id DESC;
+-- name: ArchiveRepository :one
+UPDATE repositories SET archived_at=now(), updated_at=now()
+WHERE id=$1 AND workspace_id=$2 AND archived_at IS NULL RETURNING *;
+
+-- name: RestoreRepository :one
+UPDATE repositories SET archived_at=NULL, updated_at=now()
+WHERE id=$1 AND workspace_id=$2 RETURNING *;
--- name: GetSCMConnection :one
-SELECT * FROM scm_connections
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
+-- name: DeleteRepository :execrows
+DELETE FROM repositories WHERE id=$1 AND workspace_id=$2;
--- name: UpdateSCMConnection :one
-UPDATE scm_connections
-SET name = sqlc.arg(name), base_url = sqlc.arg(base_url), auth_type = sqlc.arg(auth_type),
- secret_ref = sqlc.narg(secret_ref), updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
+-- name: RequestRepositoryDeletion :one
+UPDATE repositories SET status='deleting', updated_at=now()
+WHERE id=$1 AND workspace_id=$2
+ AND status NOT IN ('provisioning','syncing','deleting','deleted')
RETURNING *;
--- name: SoftDeleteSCMConnection :one
-UPDATE scm_connections AS connection
-SET secret_ref = NULL, deleted_at = now(), updated_at = now()
-WHERE connection.id = sqlc.arg(id) AND connection.workspace_id = sqlc.arg(workspace_id)
- AND connection.deleted_at IS NULL
- AND NOT EXISTS (
- SELECT 1 FROM repositories AS repository
- WHERE repository.connection_id = connection.id AND repository.deleted_at IS NULL
+-- name: SetRepositoryStatus :exec
+UPDATE repositories SET status=$2, updated_at=now() WHERE id=$1;
+
+-- name: SetRepositorySyncing :execrows
+UPDATE repositories
+SET status='syncing', updated_at=now()
+WHERE id=$1 AND config_version=$2 AND status NOT IN ('deleting','deleted');
+
+-- name: ProjectRepositoryOperationFailure :execrows
+UPDATE repositories
+SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END,
+ provider_type=sqlc.arg('previous_provider_type'),
+ remote_url=sqlc.arg('previous_remote_url'),
+ normalized_url=sqlc.arg('previous_normalized_url'),
+ configured_ref=sqlc.arg('previous_ref'),
+ last_error_code=sqlc.arg('last_error_code'), last_error_message=sqlc.arg('last_error_message'), updated_at=now()
+WHERE id=sqlc.arg('id') AND config_version=sqlc.arg('config_version') AND status <> 'deleted';
+
+-- name: RestoreDeletedRepositoryAfterPurgeFailure :execrows
+UPDATE repositories
+SET status='deleted', last_error_code=sqlc.arg('last_error_code'),
+ last_error_message=sqlc.arg('last_error_message'), updated_at=now()
+WHERE id=sqlc.arg('id') AND config_version=sqlc.arg('config_version')
+ AND deleted_at IS NOT NULL AND status='deleting';
+
+-- name: RestoreRepositoryAfterOperation :execrows
+UPDATE repositories
+SET status=CASE WHEN current_snapshot_id IS NULL THEN 'failed' ELSE 'ready' END,
+ provider_type=sqlc.arg('previous_provider_type'),
+ remote_url=sqlc.arg('previous_remote_url'),
+ normalized_url=sqlc.arg('previous_normalized_url'),
+ configured_ref=sqlc.arg('previous_ref'),
+ updated_at=now()
+WHERE id=sqlc.arg('id') AND config_version=sqlc.arg('config_version') AND status IN ('syncing','deleting');
+
+-- name: CreateRepositoryOperation :one
+INSERT INTO repository_operations (
+ id, repository_id, actor_user_id, provider_connection_id, credential_version, repository_version, kind,
+ requested_provider_type, requested_remote_url, requested_normalized_url, requested_ref,
+ previous_provider_type, previous_remote_url, previous_normalized_url, previous_ref, status
+) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'queued') RETURNING *;
+
+-- name: GetRepositoryOperation :one
+SELECT * FROM repository_operations WHERE id=$1;
+
+-- name: ListRepositoryOperations :many
+SELECT * FROM repository_operations
+WHERE repository_id=sqlc.arg('repository_id')
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (created_at, id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
)
-RETURNING connection.*;
+ORDER BY created_at DESC, id DESC
+LIMIT sqlc.arg('limit');
--- name: CreateRepository :one
-INSERT INTO repositories (
- id, workspace_id, connection_id, name, clone_url, normalized_url,
- ref, state
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.narg(connection_id), sqlc.arg(name),
- sqlc.arg(clone_url), sqlc.arg(normalized_url), sqlc.arg(ref), 'pending'
-)
+-- name: StartRepositoryOperation :one
+UPDATE repository_operations SET status='running', started_at=now()
+WHERE id=$1 AND status='queued' RETURNING *;
+
+-- name: FinishRepositoryOperation :one
+UPDATE repository_operations
+SET status='succeeded', outcome=$2, resolved_commit_sha=$3, snapshot_id=$4, finished_at=now()
+WHERE id=$1 AND status='running' RETURNING *;
+
+-- name: FailRepositoryOperation :one
+UPDATE repository_operations SET status='failed', error_message=$2, finished_at=now()
+WHERE id=$1 AND status IN ('queued','running') RETURNING *;
+
+-- name: CancelRepositoryOperation :one
+UPDATE repository_operations SET status='cancelled', finished_at=now()
+WHERE id=$1 AND repository_id=$2 AND status IN ('queued','running')
RETURNING *;
--- name: ListRepositories :many
-SELECT * FROM repositories
-WHERE workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
-ORDER BY created_at DESC, id DESC;
+-- name: EnsureSnapshot :one
+INSERT INTO commit_snapshots (id, repository_id, commit_sha, source_ref, git_ref, author_name, authored_at, title, source_state)
+VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'available')
+ON CONFLICT (repository_id, commit_sha) DO UPDATE
+SET id=commit_snapshots.id
+RETURNING *;
--- name: GetRepository :one
-SELECT * FROM repositories
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
-
--- name: SetRepositoryRef :execrows
-UPDATE repositories SET ref = sqlc.arg(ref), updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
-
--- name: CancelRepositorySyncJobs :exec
-UPDATE jobs
-SET status = 'cancelled', lease_owner = NULL, lease_until = NULL,
- last_error_code = 'repository.deleted', last_error_message = 'Repository was deleted',
- finished_at = now(), updated_at = now()
-WHERE workspace_id = sqlc.arg(workspace_id) AND type = 'repository.sync'
- AND payload->>'repositoryId' = sqlc.arg(repository_id)::text
- AND status IN ('queued', 'running');
-
--- name: SoftDeleteRepository :one
+-- name: SetRepositoryCurrentSnapshot :one
UPDATE repositories
-SET state = 'deleting', deleted_at = now(), updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL
-RETURNING *;
+SET current_snapshot_id=sqlc.arg('current_snapshot_id'),
+ provider_type=sqlc.arg('provider_type'), remote_url=sqlc.arg('remote_url'),
+ normalized_url=sqlc.arg('normalized_url'), configured_ref=sqlc.arg('configured_ref'),
+ status='ready', mirror_size_bytes=sqlc.arg('mirror_size_bytes'),
+ last_sync_at=now(), last_error_code=NULL, last_error_message=NULL, updated_at=now()
+WHERE id=sqlc.arg('id') AND workspace_id=sqlc.arg('workspace_id')
+ AND config_version=sqlc.arg('config_version') RETURNING *;
--- name: MarkRepositorySyncing :execrows
-UPDATE repositories SET state = 'syncing', last_error_code = '', last_error_message = '', updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
-
--- name: MarkRepositoryFailed :execrows
-UPDATE repositories SET state = 'failed', last_error_code = sqlc.arg(error_code),
- last_error_message = sqlc.arg(error_message), updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
-
--- name: MarkRepositoryReady :execrows
-UPDATE repositories SET state = 'ready', current_commit_sha = sqlc.arg(commit_sha), synced_at = now(),
- last_error_code = '', last_error_message = '', updated_at = now()
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id) AND deleted_at IS NULL;
-
--- name: ReserveRepositorySyncCooldown :one
-INSERT INTO repository_sync_controls (repository_id, workspace_id, next_allowed_at)
-VALUES (sqlc.arg(repository_id), sqlc.arg(workspace_id), sqlc.arg(next_allowed_at))
-ON CONFLICT (repository_id) DO UPDATE
-SET next_allowed_at = EXCLUDED.next_allowed_at, updated_at = now()
-WHERE repository_sync_controls.workspace_id = EXCLUDED.workspace_id
- AND repository_sync_controls.next_allowed_at <= now()
-RETURNING repository_id;
+-- name: GetSnapshot :one
+SELECT * FROM commit_snapshots WHERE id=$1 AND repository_id=$2;
+
+-- name: GetWorkspaceSnapshot :one
+SELECT s.* FROM commit_snapshots s
+JOIN repositories r ON r.id=s.repository_id
+WHERE s.id=$1 AND s.repository_id=$2 AND r.workspace_id=$3;
+
+-- name: ListRepositorySnapshots :many
+SELECT s.* FROM commit_snapshots s
+JOIN repositories r ON r.id=s.repository_id
+WHERE s.repository_id=$1 AND r.workspace_id=$2
+ AND (
+ sqlc.narg('cursor_time')::timestamptz IS NULL
+ OR (s.created_at, s.id) < (sqlc.narg('cursor_time'), sqlc.narg('cursor_id')::uuid)
+ )
+ORDER BY s.created_at DESC, s.id DESC
+LIMIT sqlc.arg('limit');
--- name: GetWorkspaceOverview :one
-SELECT
- (SELECT count(*) FROM repositories AS repository
- WHERE repository.workspace_id = sqlc.arg(target_workspace_id) AND repository.deleted_at IS NULL)::bigint AS repository_count,
- (SELECT count(*) FROM channel_instances AS channel
- WHERE channel.workspace_id = sqlc.arg(target_workspace_id) AND channel.enabled = true AND channel.deleted_at IS NULL)::bigint AS active_channel_count,
- (SELECT count(*) FROM jobs AS job
- WHERE job.workspace_id = sqlc.arg(target_workspace_id) AND job.status = 'failed')::bigint AS failed_job_count;
+-- name: MarkSnapshotsPurged :exec
+UPDATE commit_snapshots SET source_state='purged' WHERE repository_id=$1;
+
+-- name: SetRepositoryOperationWorkflowID :exec
+UPDATE repository_operations SET workflow_run_id=$2 WHERE id=$1;
+
+-- name: HasRepositoryAnalysisRuns :one
+SELECT EXISTS (SELECT 1 FROM analysis_runs WHERE repository_id=$1);
+
+-- name: CompleteRepositoryLogicalDeletion :execrows
+WITH completed AS (
+ UPDATE repository_operations
+ SET status='succeeded', outcome='no_change', finished_at=now()
+ WHERE repository_operations.id=$1 AND repository_operations.repository_id=$2 AND repository_operations.status='running'
+ RETURNING repository_operations.repository_id
+)
+UPDATE repositories
+SET status='deleted', archived_at=COALESCE(archived_at, now()), deleted_at=now(), updated_at=now()
+FROM completed
+WHERE repositories.id=completed.repository_id;
+-- name: CountManagedRepositories :one
+SELECT count(*) FROM repositories WHERE workspace_id=$1 AND status <> 'deleted';
diff --git a/sql/queries/retention.sql b/sql/queries/retention.sql
new file mode 100644
index 0000000..dd096ab
--- /dev/null
+++ b/sql/queries/retention.sql
@@ -0,0 +1,154 @@
+-- name: ClaimDueRetentionSchedules :many
+SELECT schedule.workspace_id, schedule.next_run_at, workspace.report_retention_days
+FROM retention_cleanup_schedules AS schedule
+JOIN workspaces AS workspace ON workspace.id = schedule.workspace_id
+WHERE schedule.next_run_at <= clock_timestamp()
+ORDER BY schedule.next_run_at, schedule.workspace_id
+FOR UPDATE OF schedule SKIP LOCKED
+LIMIT sqlc.arg('limit');
+
+-- name: AdvanceRetentionSchedule :execrows
+UPDATE retention_cleanup_schedules
+SET last_run_at = clock_timestamp(),
+ next_run_at = GREATEST(next_run_at + INTERVAL '1 day', clock_timestamp() + INTERVAL '1 day'),
+ updated_at = clock_timestamp()
+WHERE workspace_id = $1 AND next_run_at = $2;
+
+-- name: CreateRetentionCleanup :one
+INSERT INTO retention_cleanups (id, workspace_id, scheduled_for, retention_days, status)
+VALUES ($1, $2, $3, $4, 'queued')
+RETURNING *;
+
+-- name: GetRetentionCleanup :one
+SELECT * FROM retention_cleanups WHERE id = $1;
+
+-- name: StartRetentionCleanup :one
+UPDATE retention_cleanups
+SET status = 'running', started_at = COALESCE(started_at, clock_timestamp()), error_message = NULL
+WHERE id = $1 AND status IN ('queued', 'running')
+RETURNING *;
+
+-- name: DeleteExpiredAnalysisRuns :many
+WITH candidates AS (
+ SELECT analysis_run.id
+ FROM analysis_runs AS analysis_run
+ WHERE analysis_run.workspace_id = sqlc.arg('workspace_id')
+ AND analysis_run.status IN ('succeeded', 'failed', 'cancelled')
+ AND analysis_run.finished_at < clock_timestamp() - make_interval(days => sqlc.arg('retention_days')::integer)
+ ORDER BY analysis_run.finished_at, analysis_run.id
+ FOR UPDATE SKIP LOCKED
+ LIMIT sqlc.arg('limit')
+)
+DELETE FROM analysis_runs AS run
+USING candidates
+WHERE run.id = candidates.id
+RETURNING run.snapshot_id;
+
+-- name: LockPurgeableSnapshotCandidates :many
+SELECT snapshot.id
+FROM commit_snapshots AS snapshot
+JOIN repositories AS repository ON repository.id = snapshot.repository_id
+WHERE repository.workspace_id = sqlc.arg('workspace_id')
+ AND (
+ snapshot.source_state = 'available'
+ OR (
+ snapshot.source_state = 'purging'
+ AND (
+ snapshot.purge_cleanup_id = sqlc.arg('cleanup_id')
+ OR EXISTS (
+ SELECT 1
+ FROM retention_cleanups AS previous_cleanup
+ WHERE previous_cleanup.id = snapshot.purge_cleanup_id
+ AND previous_cleanup.status = 'failed'
+ )
+ )
+ )
+ )
+ AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id
+ AND NOT EXISTS (
+ SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id
+ )
+ORDER BY snapshot.created_at, snapshot.id
+FOR UPDATE OF snapshot SKIP LOCKED
+LIMIT sqlc.arg('limit');
+
+-- name: MarkSnapshotsPurging :many
+UPDATE commit_snapshots AS snapshot
+SET source_state = 'purging', purge_cleanup_id = sqlc.arg('cleanup_id')
+FROM repositories AS repository
+WHERE snapshot.id = ANY(sqlc.arg('snapshot_ids')::uuid[])
+ AND repository.id = snapshot.repository_id
+ AND repository.workspace_id = sqlc.arg('workspace_id')
+ AND repository.current_snapshot_id IS DISTINCT FROM snapshot.id
+ AND NOT EXISTS (
+ SELECT 1 FROM analysis_runs AS run WHERE run.snapshot_id = snapshot.id
+ )
+ AND (
+ snapshot.source_state = 'available'
+ OR (
+ snapshot.source_state = 'purging'
+ AND (
+ snapshot.purge_cleanup_id = sqlc.arg('cleanup_id')
+ OR EXISTS (
+ SELECT 1
+ FROM retention_cleanups AS previous_cleanup
+ WHERE previous_cleanup.id = snapshot.purge_cleanup_id
+ AND previous_cleanup.status = 'failed'
+ )
+ )
+ )
+ )
+RETURNING snapshot.id, snapshot.repository_id, snapshot.git_ref, snapshot.commit_sha;
+
+-- name: CompleteSnapshotPurge :execrows
+UPDATE commit_snapshots
+SET source_state = 'purged', purge_cleanup_id = NULL
+WHERE id = $1 AND repository_id = $2 AND purge_cleanup_id = $3 AND source_state = 'purging';
+
+-- name: AddRetentionCleanupProgress :execrows
+UPDATE retention_cleanups
+SET deleted_run_count = deleted_run_count + sqlc.arg('deleted_runs')::integer,
+ purged_snapshot_count = purged_snapshot_count + sqlc.arg('purged_snapshots')::integer
+WHERE id = sqlc.arg('id') AND status = 'running';
+
+-- name: ClaimRepositoriesReadyForPurge :many
+SELECT repository.*
+FROM repositories AS repository
+WHERE repository.workspace_id = sqlc.arg('workspace_id')
+ AND repository.status = 'deleted'
+ AND NOT EXISTS (
+ SELECT 1 FROM analysis_runs AS run WHERE run.repository_id = repository.id
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM repository_operations AS operation
+ WHERE operation.repository_id = repository.id
+ AND operation.kind = 'purge'
+ AND operation.status IN ('queued', 'running')
+ )
+ORDER BY repository.deleted_at, repository.id
+FOR UPDATE OF repository SKIP LOCKED
+LIMIT sqlc.arg('limit');
+
+-- name: ReactivateRepositoryPurge :execrows
+UPDATE repositories
+SET status = 'deleting', updated_at = clock_timestamp()
+WHERE id = $1 AND status = 'deleted';
+
+-- name: AddRequeuedRepositoryCount :execrows
+UPDATE retention_cleanups
+SET requeued_repository_count = requeued_repository_count + $2
+WHERE id = $1 AND status = 'running';
+
+-- name: FinishRetentionCleanup :one
+UPDATE retention_cleanups
+SET status = 'succeeded', finished_at = clock_timestamp(), error_message = NULL
+WHERE id = $1 AND status = 'running'
+RETURNING *;
+
+-- name: FailRetentionCleanup :execrows
+UPDATE retention_cleanups
+SET status = 'failed', error_message = $2, finished_at = clock_timestamp()
+WHERE id = $1 AND status IN ('queued', 'running');
+
+-- name: SetRetentionCleanupWorkflowID :exec
+UPDATE retention_cleanups SET workflow_run_id = $2 WHERE id = $1;
diff --git a/sql/queries/secrets.sql b/sql/queries/secrets.sql
deleted file mode 100644
index 8ec961e..0000000
--- a/sql/queries/secrets.sql
+++ /dev/null
@@ -1,18 +0,0 @@
--- name: CreateSecret :one
-INSERT INTO secrets (
- id, workspace_id, resource_type, resource_id, ciphertext, nonce,
- wrapped_key, wrapped_key_nonce, key_version
-) VALUES (
- sqlc.arg(id), sqlc.arg(workspace_id), sqlc.arg(resource_type), sqlc.arg(resource_id),
- sqlc.arg(ciphertext), sqlc.arg(nonce), sqlc.arg(wrapped_key),
- sqlc.arg(wrapped_key_nonce), sqlc.arg(key_version)
-)
-RETURNING *;
-
--- name: GetSecret :one
-SELECT * FROM secrets
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id);
-
--- name: DeleteSecret :exec
-DELETE FROM secrets
-WHERE id = sqlc.arg(id) AND workspace_id = sqlc.arg(workspace_id);
diff --git a/sql/queries/workflows.sql b/sql/queries/workflows.sql
new file mode 100644
index 0000000..286d0a4
--- /dev/null
+++ b/sql/queries/workflows.sql
@@ -0,0 +1,34 @@
+-- name: CreateWorkflowDispatch :one
+INSERT INTO workflow_dispatches (id, aggregate_type, aggregate_id, workflow_name, payload, status)
+VALUES ($1,$2,$3,$4,$5,'pending') RETURNING *;
+
+-- name: ClaimWorkflowDispatches :many
+WITH candidates AS (
+ SELECT id FROM workflow_dispatches
+ WHERE status='pending' AND available_at <= now()
+ ORDER BY created_at
+ FOR UPDATE SKIP LOCKED
+ LIMIT sqlc.arg('limit')
+)
+UPDATE workflow_dispatches AS dispatch
+SET attempts=dispatch.attempts+1,
+ available_at=now() + sqlc.arg('lease_duration')::interval
+FROM candidates
+WHERE dispatch.id=candidates.id
+RETURNING dispatch.*;
+
+-- name: MarkWorkflowDispatched :execrows
+UPDATE workflow_dispatches SET status='dispatched', dispatched_at=now()
+WHERE id=$1 AND status='pending';
+
+-- name: DelayWorkflowDispatch :exec
+UPDATE workflow_dispatches
+SET available_at=$2, last_error=$3
+WHERE id=$1 AND status='pending';
+
+-- name: IsWorkflowDispatchPending :one
+SELECT EXISTS(SELECT 1 FROM workflow_dispatches WHERE id=$1 AND status='pending');
+
+-- name: CancelWorkflowDispatch :exec
+UPDATE workflow_dispatches SET status='cancelled'
+WHERE aggregate_type=$1 AND aggregate_id=$2 AND status='pending';
diff --git a/sqlc.yaml b/sqlc.yaml
index fe5550d..df310ec 100644
--- a/sqlc.yaml
+++ b/sqlc.yaml
@@ -1,14 +1,23 @@
version: "2"
sql:
- engine: postgresql
- schema: migrations/postgres
+ schema: migrations/postgres/000001_initial.up.sql
queries: sql/queries
gen:
go:
- package: db
- out: internal/repository/postgres/sqlc
+ package: sqlc
+ out: internal/data/sqlc
sql_package: pgx/v5
emit_interface: true
emit_json_tags: true
emit_empty_slices: true
- emit_pointers_for_null_types: true
+ overrides:
+ - db_type: uuid
+ go_type:
+ import: github.com/google/uuid
+ type: UUID
+ - db_type: uuid
+ nullable: true
+ go_type:
+ import: github.com/google/uuid
+ type: NullUUID
From 8bee821a8621fbdb687543890176149c826daee0 Mon Sep 17 00:00:00 2001
From: fuchencong <9530753+fuchencong@users.noreply.github.com>
Date: Mon, 3 Aug 2026 14:35:08 +0800
Subject: [PATCH 2/4] ci: support Go 1.26 linting
---
.github/workflows/ci.yml | 4 ++--
.github/workflows/release.yml | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2b6c3e1..6fb19aa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,9 +49,9 @@ jobs:
git diff --exit-code
- name: Lint Go
- uses: golangci/golangci-lint-action@v8
+ uses: golangci/golangci-lint-action@v9.3.0
with:
- version: v2.5.0
+ version: v2.12.2
args: ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/...
- name: Test and build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2c771bd..b3d5791 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -58,9 +58,9 @@ jobs:
git diff --exit-code
- name: Lint Go
- uses: golangci/golangci-lint-action@v8
+ uses: golangci/golangci-lint-action@v9.3.0
with:
- version: v2.5.0
+ version: v2.12.2
args: ./api/... ./cmd/... ./internal/... ./pkg/... ./migrations/...
- name: Run release checks
From 56e4addf6149e7d845086a4e8e05db7a950142c5 Mon Sep 17 00:00:00 2001
From: fuchencong <9530753+fuchencong@users.noreply.github.com>
Date: Mon, 3 Aug 2026 14:46:33 +0800
Subject: [PATCH 3/4] fix: rebuild scc with patched dependencies
---
deploy/backend.Dockerfile | 8 +++++++-
internal/analysis/biz/idempotency_test.go | 14 +++++++-------
pkg/analyzer/scc/model.go | 2 +-
3 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/deploy/backend.Dockerfile b/deploy/backend.Dockerfile
index 51c13b9..effea47 100644
--- a/deploy/backend.Dockerfile
+++ b/deploy/backend.Dockerfile
@@ -20,7 +20,13 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
-ldflags="-s -w -X github.com/fuchencong/mooncode/internal/platform/buildinfo.Version=${VERSION} -X github.com/fuchencong/mooncode/internal/platform/buildinfo.Commit=${COMMIT} -X github.com/fuchencong/mooncode/internal/platform/buildinfo.BuildTime=${BUILD_TIME}" \
-o /out/mooncode ./cmd/mooncode
-RUN CGO_ENABLED=0 GOBIN=/out go install github.com/boyter/scc/v3@v3.4.0
+RUN mkdir /tmp/scc \
+ && cd /tmp/scc \
+ && go mod init mooncode.local/tools/scc \
+ && go get github.com/boyter/scc/v3@v3.7.0 \
+ golang.org/x/crypto@v0.54.0 \
+ golang.org/x/text@v0.40.0 \
+ && CGO_ENABLED=0 go build -trimpath -o /out/scc github.com/boyter/scc/v3
FROM debian:bookworm-slim
diff --git a/internal/analysis/biz/idempotency_test.go b/internal/analysis/biz/idempotency_test.go
index 2f7ad3d..a9442bc 100644
--- a/internal/analysis/biz/idempotency_test.go
+++ b/internal/analysis/biz/idempotency_test.go
@@ -11,24 +11,24 @@ func TestIdempotencyKeyIsStableForSameAnalysisTarget(t *testing.T) {
profileID := uuid.New()
profileSnapshot := []byte(`{"dimensionKey":"code_scale"}`)
- first := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.4.0", profileSnapshot)
- second := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.4.0", profileSnapshot)
+ first := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.7.0", profileSnapshot)
+ second := idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.7.0", profileSnapshot)
if first != second || len(first) != 64 {
t.Fatalf("unstable idempotency key: %q %q", first, second)
}
- if first == idempotencyKey(workspaceID, repositoryID, uuid.New(), profileID, "code_scale", "v1", "scc-3.4.0", profileSnapshot) {
+ if first == idempotencyKey(workspaceID, repositoryID, uuid.New(), profileID, "code_scale", "v1", "scc-3.7.0", profileSnapshot) {
t.Fatal("different snapshots produced the same idempotency key")
}
- if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v2", "scc-3.4.0", profileSnapshot) {
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v2", "scc-3.7.0", profileSnapshot) {
t.Fatal("different profile versions produced the same idempotency key")
}
- if first == idempotencyKey(workspaceID, repositoryID, snapshotID, uuid.New(), "code_scale", "v1", "scc-3.4.0", profileSnapshot) {
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, uuid.New(), "code_scale", "v1", "scc-3.7.0", profileSnapshot) {
t.Fatal("different profiles produced the same idempotency key")
}
- if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.4.0", []byte(`{"dimensionKey":"other"}`)) {
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.7.0", []byte(`{"dimensionKey":"other"}`)) {
t.Fatal("different profile snapshots produced the same idempotency key")
}
- if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.5.0", profileSnapshot) {
+ if first == idempotencyKey(workspaceID, repositoryID, snapshotID, profileID, "code_scale", "v1", "scc-3.8.0", profileSnapshot) {
t.Fatal("different analyzer versions produced the same idempotency key")
}
}
diff --git a/pkg/analyzer/scc/model.go b/pkg/analyzer/scc/model.go
index 032d7b7..7747e8b 100644
--- a/pkg/analyzer/scc/model.go
+++ b/pkg/analyzer/scc/model.go
@@ -4,7 +4,7 @@ import (
"encoding/json"
)
-const Version = "scc-3.4.0-json-v1"
+const Version = "scc-3.7.0-json-v1"
type Summary struct {
Files int64 `json:"files"`
From f41125401e463c7c48429417254f65973e850ad2 Mon Sep 17 00:00:00 2001
From: fuchencong <9530753+fuchencong@users.noreply.github.com>
Date: Mon, 3 Aug 2026 14:53:13 +0800
Subject: [PATCH 4/4] fix: update frontend runtime image
---
deploy/frontend.Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/deploy/frontend.Dockerfile b/deploy/frontend.Dockerfile
index dfbda21..14e4165 100644
--- a/deploy/frontend.Dockerfile
+++ b/deploy/frontend.Dockerfile
@@ -16,7 +16,7 @@ FROM dependencies AS build
COPY frontend ./
RUN npm run build
-FROM nginx:1.27-alpine AS production
+FROM nginx:1.30.4-alpine AS production
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist /usr/share/nginx/html