Compare commits

...

2 Commits

46 changed files with 711 additions and 227 deletions

View File

@@ -92,6 +92,14 @@ ahriman.application.handlers.patch module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.application.handlers.pkgbuild module
--------------------------------------------
.. automodule:: ahriman.application.handlers.pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
ahriman.application.handlers.rebuild module ahriman.application.handlers.rebuild module
------------------------------------------- -------------------------------------------

View File

@@ -140,6 +140,14 @@ ahriman.core.database.migrations.m016\_archive module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.database.migrations.m017\_pkgbuild module
------------------------------------------------------
.. automodule:: ahriman.core.database.migrations.m017_pkgbuild
:members:
:no-undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@@ -76,6 +76,14 @@ ahriman.core.formatters.patch\_printer module
:no-undoc-members: :no-undoc-members:
:show-inheritance: :show-inheritance:
ahriman.core.formatters.pkgbuild\_printer module
------------------------------------------------
.. automodule:: ahriman.core.formatters.pkgbuild_printer
:members:
:no-undoc-members:
:show-inheritance:
ahriman.core.formatters.printer module ahriman.core.formatters.printer module
-------------------------------------- --------------------------------------

View File

@@ -17,47 +17,57 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Box } from "@mui/material"; import "components/common/syntaxLanguages";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton"; import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
import React, { type RefObject } from "react"; import React, { type RefObject } from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
interface CodeBlockProps { interface CodeBlockProps {
preRef?: RefObject<HTMLElement | null>; content: string;
getText: () => string;
height?: number | string; height?: number | string;
language?: string;
onScroll?: () => void; onScroll?: () => void;
wordBreak?: boolean; preRef?: RefObject<HTMLElement | null>;
} }
export default function CodeBlock({ export default function CodeBlock({
preRef, content,
getText,
height, height,
language = "text",
onScroll, onScroll,
wordBreak, preRef,
}: CodeBlockProps): React.JSX.Element { }: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}> return <Box sx={{ position: "relative" }}>
<Box <Box
ref={preRef} ref={preRef}
component="pre"
onScroll={onScroll} onScroll={onScroll}
sx={{ sx={{ overflow: "auto", height }}
backgroundColor: "action.hover", >
p: 2, <SyntaxHighlighter
borderRadius: 1, language={language}
overflow: "auto", style={mode === "dark" ? vs2015 : githubGist}
height, wrapLongLines
customStyle={{
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem", fontSize: "0.8rem",
fontFamily: "monospace", fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {}, margin: 0,
minHeight: "100%",
}} }}
> >
<code> {content}
{getText()} </SyntaxHighlighter>
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
</Box> </Box>
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>; </Box>;
} }

View File

@@ -23,17 +23,17 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
interface CopyButtonProps { interface CopyButtonProps {
getText: () => string; text: string;
} }
export default function CopyButton({ getText }: CopyButtonProps): React.JSX.Element { export default function CopyButton({ text }: CopyButtonProps): React.JSX.Element {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined); const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []); useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => { const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(getText()); await navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
clearTimeout(timer.current); clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000); timer.current = setTimeout(() => setCopied(false), 2000);

View File

@@ -25,12 +25,12 @@ import type React from "react";
export default function RepositorySelect({ export default function RepositorySelect({
repositorySelect, repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element { }: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, current } = useRepository(); const { repositories, currentRepository } = useRepository();
return <FormControl fullWidth margin="normal"> return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel> <InputLabel>repository</InputLabel>
<Select <Select
value={repositorySelect.selectedKey || (current?.key ?? "")} value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
label="repository" label="repository"
onChange={event => repositorySelect.setSelectedKey(event.target.value)} onChange={event => repositorySelect.setSelectedKey(event.target.value)}
> >

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import bash from "react-syntax-highlighter/dist/esm/languages/hljs/bash";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintext";
SyntaxHighlighter.registerLanguage("bash", bash);
SyntaxHighlighter.registerLanguage("diff", diff);
SyntaxHighlighter.registerLanguage("text", plaintext);

View File

@@ -36,11 +36,11 @@ interface DashboardDialogProps {
export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element { export default function DashboardDialog({ open, onClose }: DashboardDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({ const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
enabled: open, enabled: open,
}); });

View File

@@ -105,7 +105,7 @@ export default function KeyImportDialog({ open, onClose }: KeyImportDialogProps)
/> />
{keyBody && {keyBody &&
<Box sx={{ mt: 2 }}> <Box sx={{ mt: 2 }}>
<CodeBlock getText={() => keyBody} height={300} /> <CodeBlock content={keyBody} height={300} />
</Box> </Box>
} }
</DialogContent> </DialogContent>

View File

@@ -27,6 +27,8 @@ import EventsTab from "components/package/EventsTab";
import PackageDetailsGrid from "components/package/PackageDetailsGrid"; import PackageDetailsGrid from "components/package/PackageDetailsGrid";
import PackageInfoActions from "components/package/PackageInfoActions"; import PackageInfoActions from "components/package/PackageInfoActions";
import PackagePatchesList from "components/package/PackagePatchesList"; import PackagePatchesList from "components/package/PackagePatchesList";
import PkgbuildTab from "components/package/PkgbuildTab";
import { type TabKey, tabs } from "components/package/TabKey";
import { QueryKeys } from "hooks/QueryKeys"; import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth"; import { useAuth } from "hooks/useAuth";
import { useAutoRefresh } from "hooks/useAutoRefresh"; import { useAutoRefresh } from "hooks/useAutoRefresh";
@@ -55,7 +57,7 @@ export default function PackageInfoDialog({
autoRefreshIntervals, autoRefreshIntervals,
}: PackageInfoDialogProps): React.JSX.Element { }: PackageInfoDialogProps): React.JSX.Element {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -65,11 +67,11 @@ export default function PackageInfoDialog({
setLocalPackageBase(packageBase); setLocalPackageBase(packageBase);
} }
const [tabIndex, setTabIndex] = useState(0); const [activeTab, setActiveTab] = useState<TabKey>("logs");
const [refreshDatabase, setRefreshDatabase] = useState(true); const [refreshDatabase, setRefreshDatabase] = useState(true);
const handleClose = (): void => { const handleClose = (): void => {
setTabIndex(0); setActiveTab("logs");
setRefreshDatabase(true); setRefreshDatabase(true);
onClose(); onClose();
}; };
@@ -77,16 +79,17 @@ export default function PackageInfoDialog({
const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("package-info-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packageData } = useQuery<PackageStatus[]>({ const { data: packageData } = useQuery<PackageStatus[]>({
queryKey: localPackageBase && current ? QueryKeys.package(localPackageBase, current) : ["packages"], queryKey: localPackageBase && currentRepository ? QueryKeys.package(localPackageBase, currentRepository) : ["packages"],
queryFn: localPackageBase && current ? () => client.fetch.fetchPackage(localPackageBase, current) : skipToken, queryFn: localPackageBase && currentRepository ?
() => client.fetch.fetchPackage(localPackageBase, currentRepository) : skipToken,
enabled: open, enabled: open,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: dependencies } = useQuery<Dependencies>({ const { data: dependencies } = useQuery<Dependencies>({
queryKey: localPackageBase && current ? QueryKeys.dependencies(localPackageBase, current) : ["dependencies"], queryKey: localPackageBase && currentRepository ? QueryKeys.dependencies(localPackageBase, currentRepository) : ["dependencies"],
queryFn: localPackageBase && current queryFn: localPackageBase && currentRepository ?
? () => client.fetch.fetchPackageDependencies(localPackageBase, current) : skipToken, () => client.fetch.fetchPackageDependencies(localPackageBase, currentRepository) : skipToken,
enabled: open, enabled: open,
}); });
@@ -102,11 +105,12 @@ export default function PackageInfoDialog({
const headerStyle = status ? StatusHeaderStyles[status.status] : {}; const headerStyle = status ? StatusHeaderStyles[status.status] : {};
const handleUpdate: () => Promise<void> = async () => { const handleUpdate: () => Promise<void> = async () => {
if (!localPackageBase || !current) { if (!localPackageBase || !currentRepository) {
return; return;
} }
try { try {
await client.service.servicePackageAdd(current, { packages: [localPackageBase], refresh: refreshDatabase }); await client.service.servicePackageAdd(
currentRepository, { packages: [localPackageBase], refresh: refreshDatabase });
showSuccess("Success", `Run update for packages ${localPackageBase}`); showSuccess("Success", `Run update for packages ${localPackageBase}`);
} catch (exception) { } catch (exception) {
showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`); showError("Action failed", `Package update failed: ${ApiError.errorDetail(exception)}`);
@@ -114,11 +118,11 @@ export default function PackageInfoDialog({
}; };
const handleRemove: () => Promise<void> = async () => { const handleRemove: () => Promise<void> = async () => {
if (!localPackageBase || !current) { if (!localPackageBase || !currentRepository) {
return; return;
} }
try { try {
await client.service.servicePackageRemove(current, [localPackageBase]); await client.service.servicePackageRemove(currentRepository, [localPackageBase]);
showSuccess("Success", `Packages ${localPackageBase} have been removed`); showSuccess("Success", `Packages ${localPackageBase} have been removed`);
onClose(); onClose();
} catch (exception) { } catch (exception) {
@@ -156,25 +160,26 @@ export default function PackageInfoDialog({
/> />
<Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}> <Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}>
<Tabs value={tabIndex} onChange={(_, index: number) => setTabIndex(index)}> <Tabs value={activeTab} onChange={(_, tab: TabKey) => setActiveTab(tab)}>
<Tab label="Build logs" /> {tabs.map(({ key, label }) => <Tab key={key} value={key} label={label} />)}
<Tab label="Changes" />
<Tab label="Events" />
</Tabs> </Tabs>
</Box> </Box>
{tabIndex === 0 && localPackageBase && current && {activeTab === "logs" && localPackageBase && currentRepository &&
<BuildLogsTab <BuildLogsTab
packageBase={localPackageBase} packageBase={localPackageBase}
repository={current} repository={currentRepository}
refreshInterval={autoRefresh.interval} refreshInterval={autoRefresh.interval}
/> />
} }
{tabIndex === 1 && localPackageBase && current && {activeTab === "changes" && localPackageBase && currentRepository &&
<ChangesTab packageBase={localPackageBase} repository={current} /> <ChangesTab packageBase={localPackageBase} repository={currentRepository} />
} }
{tabIndex === 2 && localPackageBase && current && {activeTab === "pkgbuild" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={current} /> <PkgbuildTab packageBase={localPackageBase} repository={currentRepository} />
}
{activeTab === "events" && localPackageBase && currentRepository &&
<EventsTab packageBase={localPackageBase} repository={currentRepository} />
} }
</> </>
} }

View File

@@ -22,14 +22,15 @@ import { useRepository } from "hooks/useRepository";
import type React from "react"; import type React from "react";
export default function Navbar(): React.JSX.Element | null { export default function Navbar(): React.JSX.Element | null {
const { repositories, current, setCurrent } = useRepository(); const { repositories, currentRepository, setCurrentRepository } = useRepository();
if (repositories.length === 0 || !current) { if (repositories.length === 0 || !currentRepository) {
return null; return null;
} }
const currentIndex = repositories.findIndex(repository => const currentIndex = repositories.findIndex(repository =>
repository.architecture === current.architecture && repository.repository === current.repository, repository.architecture === currentRepository.architecture &&
repository.repository === currentRepository.repository,
); );
return <Box sx={{ borderBottom: 1, borderColor: "divider" }}> return <Box sx={{ borderBottom: 1, borderColor: "divider" }}>
@@ -38,7 +39,7 @@ export default function Navbar(): React.JSX.Element | null {
onChange={(_, newValue: number) => { onChange={(_, newValue: number) => {
const repository = repositories[newValue]; const repository = repositories[newValue];
if (repository) { if (repository) {
setCurrent(repository); setCurrentRepository(repository);
} }
}} }}
variant="scrollable" variant="scrollable"

View File

@@ -175,10 +175,9 @@ export default function BuildLogsTab({
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<CodeBlock <CodeBlock
preRef={preRef} preRef={preRef}
getText={() => displayedLogs} content={displayedLogs}
height={400} height={400}
onScroll={handleScroll} onScroll={handleScroll}
wordBreak
/> />
</Box> </Box>
</Box>; </Box>;

View File

@@ -17,20 +17,10 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import { Box } from "@mui/material"; import CodeBlock from "components/common/CodeBlock";
import { useQuery } from "@tanstack/react-query"; import { usePackageChanges } from "hooks/usePackageChanges";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import { useThemeMode } from "hooks/useThemeMode";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId"; import type { RepositoryId } from "models/RepositoryId";
import React from "react"; import React from "react";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import diff from "react-syntax-highlighter/dist/esm/languages/hljs/diff";
import { githubGist, vs2015 } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps { interface ChangesTabProps {
packageBase: string; packageBase: string;
@@ -38,35 +28,7 @@ interface ChangesTabProps {
} }
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element { export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const client = useClient(); const data = usePackageChanges(packageBase, repository);
const { mode } = useThemeMode();
const { data } = useQuery<Changes>({ return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
const changesText = data?.changes ?? "";
return <Box sx={{ position: "relative", mt: 1 }}>
<SyntaxHighlighter
language="diff"
style={mode === "dark" ? vs2015 : githubGist}
customStyle={{
padding: "16px",
borderRadius: "4px",
overflow: "auto",
height: 400,
fontSize: "0.8rem",
fontFamily: "monospace",
margin: 0,
}}
>
{changesText}
</SyntaxHighlighter>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={() => changesText} />
</Box>
</Box>;
} }

View File

@@ -0,0 +1,34 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
import React from "react";
interface PkgbuildTabProps {
packageBase: string;
repository: RepositoryId;
}
export default function PkgbuildTab({ packageBase, repository }: PkgbuildTabProps): React.JSX.Element {
const data = usePackageChanges(packageBase, repository);
return <CodeBlock language="bash" content={data?.pkgbuild ?? ""} height={400} />;
}

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export type TabKey = "logs" | "changes" | "pkgbuild" | "events";
export const tabs: { key: TabKey; label: string }[] = [
{ key: "logs", label: "Build logs" },
{ key: "changes", label: "Changes" },
{ key: "pkgbuild", label: "PKGBUILD" },
{ key: "events", label: "Events" },
];

View File

@@ -22,9 +22,9 @@ import { createContext } from "react";
export interface RepositoryContextValue { export interface RepositoryContextValue {
repositories: RepositoryId[]; repositories: RepositoryId[];
current: RepositoryId | null; currentRepository: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void; setRepositories: (repositories: RepositoryId[]) => void;
setCurrent: (repository: RepositoryId) => void; setCurrentRepository: (repository: RepositoryId) => void;
} }
export const RepositoryContext = createContext<RepositoryContextValue | null>(null); export const RepositoryContext = createContext<RepositoryContextValue | null>(null);

View File

@@ -34,20 +34,20 @@ export function RepositoryProvider({ children }: { children: ReactNode }): React
const [repositories, setRepositories] = useState<RepositoryId[]>([]); const [repositories, setRepositories] = useState<RepositoryId[]>([]);
const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot); const hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const current = useMemo(() => { const currentRepository = useMemo(() => {
if (repositories.length === 0) { if (repositories.length === 0) {
return null; return null;
} }
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null; return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]); }, [repositories, hash]);
const setCurrent = useCallback((repository: RepositoryId) => { const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key; window.location.hash = repository.key;
}, []); }, []);
const value = useMemo(() => ({ const value = useMemo(() => ({
repositories, current, setRepositories, setCurrent, repositories, currentRepository, setRepositories, setCurrentRepository,
}), [repositories, current, setCurrent]); }), [repositories, currentRepository, setCurrentRepository]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>; return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
} }

View File

@@ -37,7 +37,7 @@ export function usePackageActions(
setSelectionModel: (model: string[]) => void, setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult { ): UsePackageActionsResult {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { showSuccess, showError } = useNotification(); const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -50,13 +50,13 @@ export function usePackageActions(
action: (repository: RepositoryId) => Promise<string>, action: (repository: RepositoryId) => Promise<string>,
errorMessage: string, errorMessage: string,
): Promise<void> => { ): Promise<void> => {
if (!current) { if (!currentRepository) {
return; return;
} }
try { try {
const successMessage = await action(current); const successMessage = await action(currentRepository);
showSuccess("Success", successMessage); showSuccess("Success", successMessage);
invalidate(current); invalidate(currentRepository);
setSelectionModel([]); setSelectionModel([]);
} catch (exception) { } catch (exception) {
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`); showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
@@ -64,8 +64,8 @@ export function usePackageActions(
}; };
const handleReload: () => void = () => { const handleReload: () => void = () => {
if (current !== null) { if (currentRepository !== null) {
invalidate(current); invalidate(currentRepository);
} }
}; };

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2021-2026 ahriman team.
*
* This file is part of ahriman
* (see https://github.com/arcan1s/ahriman).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useQuery } from "@tanstack/react-query";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import type { RepositoryId } from "models/RepositoryId";
export function usePackageChanges(packageBase: string, repository: RepositoryId): Changes | undefined {
const client = useClient();
const { data } = useQuery<Changes>({
queryKey: QueryKeys.changes(packageBase, repository),
queryFn: () => client.fetch.fetchPackageChanges(packageBase, repository),
enabled: !!packageBase,
});
return data;
}

View File

@@ -39,20 +39,20 @@ export interface UsePackageDataResult {
export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult { export function usePackageData(autoRefreshIntervals: AutoRefreshInterval[]): UsePackageDataResult {
const client = useClient(); const client = useClient();
const { current } = useRepository(); const { currentRepository } = useRepository();
const { isAuthorized } = useAuth(); const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals)); const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({ const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"], queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"], queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken, queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false, refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
}); });

View File

@@ -29,10 +29,10 @@ export interface SelectedRepositoryResult {
} }
export function useSelectedRepository(): SelectedRepositoryResult { export function useSelectedRepository(): SelectedRepositoryResult {
const { repositories, current } = useRepository(); const { repositories, currentRepository } = useRepository();
const [selectedKey, setSelectedKey] = useState(""); const [selectedKey, setSelectedKey] = useState("");
let selectedRepository: RepositoryId | null = current; let selectedRepository: RepositoryId | null = currentRepository;
if (selectedKey) { if (selectedKey) {
const repository = repositories.find(repository => repository.key === selectedKey); const repository = repositories.find(repository => repository.key === selectedKey);
if (repository) { if (repository) {

View File

@@ -20,4 +20,5 @@
export interface Changes { export interface Changes {
changes?: string; changes?: string;
last_commit_sha?: string; last_commit_sha?: string;
pkgbuild?: string;
} }

View File

@@ -45,7 +45,7 @@ class ApplicationRepository(ApplicationProperties):
if last_commit_sha is None: if last_commit_sha is None:
continue # skip check in case if we can't calculate diff continue # skip check in case if we can't calculate diff
changes = self.repository.package_changes(package, last_commit_sha) if (changes := self.repository.package_changes(package, last_commit_sha)) is not None:
self.repository.reporter.package_changes_update(package.base, changes) self.repository.reporter.package_changes_update(package.base, changes)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None: def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:

View File

@@ -54,7 +54,7 @@ class Change(Handler):
case Action.List: case Action.List:
changes = client.package_changes_get(args.package) changes = client.package_changes_get(args.package)
ChangesPrinter(changes)(verbose=True, separator="") ChangesPrinter(changes)(verbose=True, separator="")
Change.check_status(args.exit_code, not changes.is_empty) Change.check_status(args.exit_code, changes.changes is not None)
case Action.Remove: case Action.Remove:
client.package_changes_update(args.package, Changes()) client.package_changes_update(args.package, Changes())

View File

@@ -0,0 +1,101 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import argparse
from dataclasses import replace
from ahriman.application.application import Application
from ahriman.application.handlers.handler import Handler, SubParserAction
from ahriman.core.configuration import Configuration
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.action import Action
from ahriman.models.repository_id import RepositoryId
class Pkgbuild(Handler):
"""
package pkgbuild handler
"""
ALLOW_MULTI_ARCHITECTURE_RUN = False # conflicting io
@classmethod
def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *,
report: bool) -> None:
"""
callback for command line
Args:
args(argparse.Namespace): command line args
repository_id(RepositoryId): repository unique identifier
configuration(Configuration): configuration instance
report(bool): force enable or disable reporting
"""
application = Application(repository_id, configuration, report=True)
client = application.repository.reporter
match args.action:
case Action.List:
changes = client.package_changes_get(args.package)
PkgbuildPrinter(changes)(verbose=True, separator="")
Pkgbuild.check_status(args.exit_code, changes.pkgbuild is not None)
case Action.Remove:
changes = client.package_changes_get(args.package)
client.package_changes_update(args.package, replace(changes, pkgbuild=None))
@staticmethod
def _set_package_pkgbuild_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild", help="get package pkgbuild",
description="retrieve package PKGBUILD stored in database",
epilog="This command requests package status from the web interface "
"if it is available.")
parser.add_argument("package", help="package base")
parser.add_argument("-e", "--exit-code", help="return non-zero exit status if result is empty",
action="store_true")
parser.set_defaults(action=Action.List, lock=None, quiet=True, report=False, unsafe=True)
return parser
@staticmethod
def _set_package_pkgbuild_remove_parser(root: SubParserAction) -> argparse.ArgumentParser:
"""
add parser for package pkgbuild remove subcommand
Args:
root(SubParserAction): subparsers for the commands
Returns:
argparse.ArgumentParser: created argument parser
"""
parser = root.add_parser("package-pkgbuild-remove", help="remove package pkgbuild",
description="remove the package PKGBUILD stored remotely")
parser.add_argument("package", help="package base")
parser.set_defaults(action=Action.Remove, exit_code=False, lock=None, quiet=True, report=False, unsafe=True)
return parser
arguments = [_set_package_pkgbuild_parser, _set_package_pkgbuild_remove_parser]

View File

@@ -26,6 +26,7 @@ from typing import ClassVar
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, utcnow, walk from ahriman.core.utils import check_output, utcnow, walk
from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild import Pkgbuild from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -51,24 +52,25 @@ class Sources(LazyLogging):
} }
@staticmethod @staticmethod
def changes(source_dir: Path, last_commit_sha: str | None) -> str | None: def changes(source_dir: Path, last_commit_sha: str) -> Changes:
""" """
extract changes from the last known commit if available extract changes from the last known commit if available
Args: Args:
source_dir(Path): local path to directory with source files source_dir(Path): local path to directory with source files
last_commit_sha(str | None): last known commit hash last_commit_sha(str): last known commit hash
Returns: Returns:
str | None: changes from the last commit if available or ``None`` otherwise Changes: changes from the last commit if available
""" """
if last_commit_sha is None:
return None # no previous reference found
instance = Sources() instance = Sources()
diff = None
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None: if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
return instance.diff(source_dir, last_commit_sha) diff = instance.diff(source_dir, last_commit_sha)
return None pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD"))
return Changes(last_commit_sha, diff, pkgbuild)
@staticmethod @staticmethod
def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]: def extend_architectures(sources_dir: Path, architecture: str) -> list[PkgbuildPatch]:
@@ -413,3 +415,17 @@ class Sources(LazyLogging):
cwd=sources_dir, input_data=patch.serialize(), logger=self.logger) cwd=sources_dir, input_data=patch.serialize(), logger=self.logger)
else: else:
patch.write(sources_dir / "PKGBUILD") patch.write(sources_dir / "PKGBUILD")
def read(self, sources_dir: Path, commit_sha: str, path: Path) -> str:
"""
read file content from the specified commit
Args:
sources_dir(Path): local path to git repository
commit_sha(str): commit hash to read from
path(Path): path to file inside the repository
Returns:
str: file content at specified commit
"""
return check_output(*self.git(), "show", f"{commit_sha}:{path}", cwd=sources_dir, logger=self.logger)

View File

@@ -0,0 +1,25 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__all__ = ["steps"]
steps = [
"""alter table package_changes add column pkgbuild text""",
]

View File

@@ -45,10 +45,10 @@ class ChangesOperations(Operations):
def run(connection: Connection) -> Changes: def run(connection: Connection) -> Changes:
return next( return next(
( (
Changes(row["last_commit_sha"], row["changes"] or None) Changes(row["last_commit_sha"], row["changes"] or None, row["pkgbuild"] or None)
for row in connection.execute( for row in connection.execute(
""" """
select last_commit_sha, changes from package_changes select last_commit_sha, changes, pkgbuild from package_changes
where package_base = :package_base and repository = :repository where package_base = :package_base and repository = :repository
""", """,
{ {
@@ -77,16 +77,17 @@ class ChangesOperations(Operations):
connection.execute( connection.execute(
""" """
insert into package_changes insert into package_changes
(package_base, last_commit_sha, changes, repository) (package_base, last_commit_sha, changes, pkgbuild, repository)
values values
(:package_base, :last_commit_sha, :changes ,:repository) (:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
on conflict (package_base, repository) do update set on conflict (package_base, repository) do update set
last_commit_sha = :last_commit_sha, changes = :changes last_commit_sha = :last_commit_sha, changes = :changes, pkgbuild = :pkgbuild
""", """,
{ {
"package_base": package_base, "package_base": package_base,
"last_commit_sha": changes.last_commit_sha, "last_commit_sha": changes.last_commit_sha,
"changes": changes.changes, "changes": changes.changes,
"pkgbuild": changes.pkgbuild,
"repository": repository_id.id, "repository": repository_id.id,
}) })

View File

@@ -26,6 +26,7 @@ from ahriman.core.formatters.event_stats_printer import EventStatsPrinter
from ahriman.core.formatters.package_printer import PackagePrinter from ahriman.core.formatters.package_printer import PackagePrinter
from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter from ahriman.core.formatters.package_stats_printer import PackageStatsPrinter
from ahriman.core.formatters.patch_printer import PatchPrinter from ahriman.core.formatters.patch_printer import PatchPrinter
from ahriman.core.formatters.pkgbuild_printer import PkgbuildPrinter
from ahriman.core.formatters.printer import Printer from ahriman.core.formatters.printer import Printer
from ahriman.core.formatters.repository_printer import RepositoryPrinter from ahriman.core.formatters.repository_printer import RepositoryPrinter
from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter from ahriman.core.formatters.repository_stats_printer import RepositoryStatsPrinter

View File

@@ -45,7 +45,7 @@ class ChangesPrinter(Printer):
Returns: Returns:
list[Property]: list of content properties list[Property]: list of content properties
""" """
if self.changes.is_empty: if self.changes.changes is None:
return [] return []
return [Property("", self.changes.changes, is_required=True, indent=0)] return [Property("", self.changes.changes, is_required=True, indent=0)]
@@ -57,6 +57,6 @@ class ChangesPrinter(Printer):
Returns: Returns:
str | None: content title if it can be generated and ``None`` otherwise str | None: content title if it can be generated and ``None`` otherwise
""" """
if self.changes.is_empty: if self.changes.changes is None:
return None return None
return self.changes.last_commit_sha return self.changes.last_commit_sha

View File

@@ -0,0 +1,62 @@
#
# Copyright (c) 2021-2026 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from ahriman.core.formatters.printer import Printer
from ahriman.models.changes import Changes
from ahriman.models.property import Property
class PkgbuildPrinter(Printer):
"""
print content of the pkgbuild stored in changes
Attributes:
changes(Changes): package changes
"""
def __init__(self, changes: Changes) -> None:
"""
Args:
changes(Changes): package changes
"""
Printer.__init__(self)
self.changes = changes
def properties(self) -> list[Property]:
"""
convert content into printable data
Returns:
list[Property]: list of content properties
"""
if self.changes.pkgbuild is None:
return []
return [Property("", self.changes.pkgbuild, is_required=True, indent=0)]
# pylint: disable=redundant-returns-doc
def title(self) -> str | None:
"""
generate entry title from content
Returns:
str | None: content title if it can be generated and ``None`` otherwise
"""
if self.changes.pkgbuild is None:
return None
return self.changes.last_commit_sha

View File

@@ -204,7 +204,8 @@ class Executor(PackageInfo, Cleaner):
# update commit hash for changes keeping current diff if there is any # update commit hash for changes keeping current diff if there is any
changes = self.reporter.package_changes_get(single.base) changes = self.reporter.package_changes_get(single.base)
self.reporter.package_changes_update(single.base, Changes(commit_sha, changes.changes)) self.reporter.package_changes_update(
single.base, Changes(commit_sha, changes.changes, changes.pkgbuild))
# update dependencies list # update dependencies list
package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths) package_archive = PackageArchive(self.paths.build_root, single, self.pacman, self.scan_paths)

View File

@@ -102,27 +102,25 @@ class PackageInfo(RepositoryProperties):
self.logger.exception("could not load package from %s", full_path) self.logger.exception("could not load package from %s", full_path)
return list(result.values()) return list(result.values())
def package_changes(self, package: Package, last_commit_sha: str | None) -> Changes: def package_changes(self, package: Package, last_commit_sha: str) -> Changes | None:
""" """
extract package change for the package since last commit if available extract package change for the package since last commit if available
Args: Args:
package(Package): package properties package(Package): package properties
last_commit_sha(str | None): last known commit hash last_commit_sha(str): last known commit hash
Returns: Returns:
Changes: changes if available Changes | None: changes if available
""" """
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name: with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
dir_path = Path(dir_name) dir_path = Path(dir_name)
patches = self.reporter.package_patches_get(package.base, None) patches = self.reporter.package_patches_get(package.base, None)
current_commit_sha = Sources.load(dir_path, package, patches, self.paths) current_commit_sha = Sources.load(dir_path, package, patches, self.paths)
changes: str | None = None
if current_commit_sha != last_commit_sha: if current_commit_sha != last_commit_sha:
changes = Sources.changes(dir_path, last_commit_sha) return Sources.changes(dir_path, last_commit_sha)
return None
return Changes(last_commit_sha, changes)
def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]: def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]:
""" """

View File

@@ -31,20 +31,12 @@ class Changes:
Attributes: Attributes:
last_commit_sha(str | None): last commit hash last_commit_sha(str | None): last commit hash
changes(str | None): package change since the last commit if available changes(str | None): package change since the last commit if available
pkgbuild(str | None): original PKGBUILD content if available
""" """
last_commit_sha: str | None = None last_commit_sha: str | None = None
changes: str | None = None changes: str | None = None
pkgbuild: str | None = None
@property
def is_empty(self) -> bool:
"""
validate that changes are not empty
Returns:
bool: ``True`` in case if changes are not set and ``False`` otherwise
"""
return self.changes is None
@classmethod @classmethod
def from_json(cls, dump: dict[str, Any]) -> Self: def from_json(cls, dump: dict[str, Any]) -> Self:

View File

@@ -32,3 +32,6 @@ class ChangesSchema(Schema):
"description": "Last recorded commit hash", "description": "Last recorded commit hash",
"example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6", "example": "f1875edca1eb8fc0e55c41d1cae5fa05b6b7c6",
}) })
pkgbuild = fields.String(metadata={
"description": "Original PKGBUILD content",
})

View File

@@ -92,10 +92,11 @@ class ChangesView(StatusViewGuard, BaseView):
data = await self.request.json() data = await self.request.json()
last_commit_sha = data.get("last_commit_sha") # empty/null meant removal last_commit_sha = data.get("last_commit_sha") # empty/null meant removal
change = data.get("changes") change = data.get("changes")
pkgbuild = data.get("pkgbuild")
except Exception as ex: except Exception as ex:
raise HTTPBadRequest(reason=str(ex)) raise HTTPBadRequest(reason=str(ex))
changes = Changes(last_commit_sha, change) changes = Changes(last_commit_sha, change, pkgbuild)
self.service().package_changes_update(package_base, changes) self.service().package_changes_update(package_base, changes)
raise HTTPNoContent raise HTTPNoContent

View File

@@ -18,7 +18,7 @@ def test_changes(application_repository: ApplicationRepository, package_ahriman:
""" """
must generate changes for the packages must generate changes for the packages
""" """
changes = Changes("hash", "change") changes = Changes("sha", "change")
hashes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes) hashes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
changes_mock = mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=changes) changes_mock = mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=changes)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update") report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
@@ -42,6 +42,20 @@ def test_changes_skip(application_repository: ApplicationRepository, package_ahr
report_mock.assert_not_called() report_mock.assert_not_called()
def test_changes_no_update(application_repository: ApplicationRepository, package_ahriman: Package,
mocker: MockerFixture) -> None:
"""
must skip update if package_changes returns None (no new commits)
"""
changes = Changes("sha", "change")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
mocker.patch("ahriman.core.repository.Repository.package_changes", return_value=None)
report_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
application_repository.changes([package_ahriman])
report_mock.assert_not_called()
def test_clean_cache(application_repository: ApplicationRepository, mocker: MockerFixture) -> None: def test_clean_cache(application_repository: ApplicationRepository, mocker: MockerFixture) -> None:
""" """
must clean cache directory must clean cache directory

View File

@@ -0,0 +1,100 @@
import argparse
import pytest
from pytest_mock import MockerFixture
from ahriman.application.handlers.pkgbuild import Pkgbuild
from ahriman.core.configuration import Configuration
from ahriman.core.database import SQLite
from ahriman.core.repository import Repository
from ahriman.models.action import Action
from ahriman.models.changes import Changes
def _default_args(args: argparse.Namespace) -> argparse.Namespace:
"""
default arguments for these test cases
Args:
args(argparse.Namespace): command line arguments fixture
Returns:
argparse.Namespace: generated arguments for these test cases
"""
args.action = Action.List
args.exit_code = False
args.package = "package"
return args
def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must run command
"""
args = _default_args(args)
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
application_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("sha", "change", "pkgbuild content"))
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
print_mock = mocker.patch("ahriman.core.formatters.Printer.print")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
application_mock.assert_called_once_with(args.package)
check_mock.assert_called_once_with(False, True)
print_mock.assert_called_once_with(verbose=True, log_fn=pytest.helpers.anyvar(int), separator="")
def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must raise ExitCode exception on empty pkgbuild result
"""
args = _default_args(args)
args.exit_code = True
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=Changes())
check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
check_mock.assert_called_once_with(True, False)
def test_run_remove(args: argparse.Namespace, configuration: Configuration, repository: Repository,
mocker: MockerFixture) -> None:
"""
must remove package pkgbuild
"""
args = _default_args(args)
args.action = Action.Remove
mocker.patch("ahriman.core.repository.Repository.load", return_value=repository)
changes = Changes("sha", "change", "pkgbuild content")
mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", return_value=changes)
update_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
update_mock.assert_called_once_with(args.package, Changes("sha", "change", None))
def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, database: SQLite,
mocker: MockerFixture) -> None:
"""
must create application object with native reporting
"""
args = _default_args(args)
mocker.patch("ahriman.core.database.SQLite.load", return_value=database)
load_mock = mocker.patch("ahriman.core.repository.Repository.load")
_, repository_id = configuration.check_loaded()
Pkgbuild.run(args, repository_id, configuration, report=False)
load_mock.assert_called_once_with(repository_id, configuration, database, report=True, refresh_pacman_database=0)
def test_disallow_multi_architecture_run() -> None:
"""
must not allow multi architecture run
"""
assert not Pkgbuild.ALLOW_MULTI_ARCHITECTURE_RUN

View File

@@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
from ahriman.core.build_tools.sources import Sources from ahriman.core.build_tools.sources import Sources
from ahriman.core.exceptions import CalledProcessError from ahriman.core.exceptions import CalledProcessError
from ahriman.models.changes import Changes
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource from ahriman.models.package_source import PackageSource
from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -19,35 +20,27 @@ def test_changes(mocker: MockerFixture) -> None:
""" """
fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until") fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until")
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff", return_value="diff")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
local = Path("local") local = Path("local")
last_commit_sha = "sha" last_commit_sha = "sha"
assert Sources.changes(local, last_commit_sha) == "diff" assert Sources.changes(local, last_commit_sha) == Changes(last_commit_sha, "diff", "pkgbuild")
fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha) fetch_mock.assert_called_once_with(local, commit_sha=last_commit_sha)
diff_mock.assert_called_once_with(local, last_commit_sha) diff_mock.assert_called_once_with(local, last_commit_sha)
read_mock.assert_called_once_with(local, "HEAD", Path("PKGBUILD"))
def test_changes_skip(mocker: MockerFixture) -> None:
"""
must return none in case if commit sha is not available
"""
fetch_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until")
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff")
assert Sources.changes(Path("local"), None) is None
fetch_mock.assert_not_called()
diff_mock.assert_not_called()
def test_changes_unknown_commit(mocker: MockerFixture) -> None: def test_changes_unknown_commit(mocker: MockerFixture) -> None:
""" """
must return none in case if commit sha wasn't found at the required depth must return changes without diff in case if commit sha wasn't found at the required depth
""" """
mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until", return_value=None) mocker.patch("ahriman.core.build_tools.sources.Sources.fetch_until", return_value=None)
diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff") diff_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.diff")
read_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.read", return_value="pkgbuild")
assert Sources.changes(Path("local"), "sha") is None assert Sources.changes(Path("local"), "sha") == Changes("sha", None, "pkgbuild")
diff_mock.assert_not_called() diff_mock.assert_not_called()
read_mock.assert_called_once_with(Path("local"), "HEAD", Path("PKGBUILD"))
def test_extend_architectures(mocker: MockerFixture) -> None: def test_extend_architectures(mocker: MockerFixture) -> None:
@@ -603,3 +596,12 @@ def test_patch_apply_function(sources: Sources, mocker: MockerFixture) -> None:
sources.patch_apply(local, patch) sources.patch_apply(local, patch)
write_mock.assert_called_once_with(local / "PKGBUILD") write_mock.assert_called_once_with(local / "PKGBUILD")
def test_read(sources: Sources, mocker: MockerFixture) -> None:
"""
must read file from commit
"""
check_output_mock = mocker.patch("ahriman.core.build_tools.sources.check_output", return_value="content")
assert sources.read(Path("local"), "sha", Path("PKGBUILD")) == "content"
check_output_mock.assert_called_once()

View File

@@ -0,0 +1,8 @@
from ahriman.core.database.migrations.m017_pkgbuild import steps
def test_migration_pkgbuild() -> None:
"""
migration must not be empty
"""
assert steps

View File

@@ -8,34 +8,35 @@ def test_changes_insert_get(database: SQLite, package_ahriman: Package) -> None:
""" """
must insert and get changes must insert and get changes
""" """
database.changes_insert(package_ahriman.base, Changes("sha1", "change1")) changes1 = Changes("sha1", "change1", "pkgbuild1")
assert database.changes_get(package_ahriman.base).changes == "change1" database.changes_insert(package_ahriman.base, changes1)
assert database.changes_get(package_ahriman.base) == changes1
changes2 = Changes("sha2", "change2", "pkgbuild2")
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"), database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
RepositoryId("i686", database._repository_id.name)) assert database.changes_get(package_ahriman.base) == changes1
assert database.changes_get(package_ahriman.base).changes == "change1" assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None: def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None:
""" """
must remove changes for the package must remove changes for the package
""" """
database.changes_insert(package_ahriman.base, Changes("sha1", "change1")) changes3 = Changes("sha3", "change3", "pkgbuild3")
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3")) database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"), database.changes_insert(package_python_schedule.base, changes3)
database.changes_insert(package_ahriman.base, Changes("sha2", "change2", "pkgbuild2"),
RepositoryId("i686", database._repository_id.name)) RepositoryId("i686", database._repository_id.name))
database.changes_remove(package_ahriman.base) database.changes_remove(package_ahriman.base)
assert database.changes_get(package_ahriman.base).changes is None assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base).changes == "change3" assert database.changes_get(package_python_schedule.base) == changes3
# insert null # insert null
database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name)) database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name))
assert database.changes_get( assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes is None package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes is None
assert database.changes_get(package_python_schedule.base).changes == "change3" assert database.changes_get(package_python_schedule.base) == changes3
def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package, def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
@@ -43,13 +44,12 @@ def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
""" """
must remove all changes for the repository must remove all changes for the repository
""" """
database.changes_insert(package_ahriman.base, Changes("sha1", "change1")) changes2 = Changes("sha2", "change2", "pkgbuild2")
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3")) database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"), database.changes_insert(package_python_schedule.base, Changes("sha3", "change3", "pkgbuild3"))
RepositoryId("i686", database._repository_id.name)) database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
database.changes_remove(None) database.changes_remove(None)
assert database.changes_get(package_ahriman.base).changes is None assert database.changes_get(package_ahriman.base).changes is None
assert database.changes_get(package_python_schedule.base).changes is None assert database.changes_get(package_python_schedule.base).changes is None
assert database.changes_get( assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"

View File

@@ -2,24 +2,26 @@ import pytest
from pathlib import Path from pathlib import Path
from ahriman.core.formatters import \ from ahriman.core.formatters import (
AurPrinter, \ AurPrinter,
ChangesPrinter, \ ChangesPrinter,
ConfigurationPathsPrinter, \ ConfigurationPathsPrinter,
ConfigurationPrinter, \ ConfigurationPrinter,
EventStatsPrinter, \ EventStatsPrinter,
PackagePrinter, \ PackagePrinter,
PackageStatsPrinter, \ PackageStatsPrinter,
PatchPrinter, \ PatchPrinter,
RepositoryPrinter, \ PkgbuildPrinter,
RepositoryStatsPrinter, \ RepositoryPrinter,
StatusPrinter, \ RepositoryStatsPrinter,
StringPrinter, \ StatusPrinter,
TreePrinter, \ StringPrinter,
UpdatePrinter, \ TreePrinter,
UserPrinter, \ UpdatePrinter,
ValidationPrinter, \ UserPrinter,
ValidationPrinter,
VersionPrinter VersionPrinter
)
from ahriman.models.aur_package import AURPackage from ahriman.models.aur_package import AURPackage
from ahriman.models.build_status import BuildStatus from ahriman.models.build_status import BuildStatus
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
@@ -55,6 +57,17 @@ def changes_printer() -> ChangesPrinter:
return ChangesPrinter(Changes("sha", "changes")) return ChangesPrinter(Changes("sha", "changes"))
@pytest.fixture
def pkgbuild_printer() -> PkgbuildPrinter:
"""
fixture for pkgbuild printer
Returns:
PkgbuildPrinter: pkgbuild printer test instance
"""
return PkgbuildPrinter(Changes("sha", "changes", "pkgbuild content"))
@pytest.fixture @pytest.fixture
def configuration_paths_printer() -> ConfigurationPathsPrinter: def configuration_paths_printer() -> ConfigurationPathsPrinter:
""" """

View File

@@ -0,0 +1,32 @@
from ahriman.core.formatters import PkgbuildPrinter
from ahriman.models.changes import Changes
def test_properties(pkgbuild_printer: PkgbuildPrinter) -> None:
"""
must return non-empty properties list
"""
assert pkgbuild_printer.properties()
def test_properties_empty() -> None:
"""
must return empty properties list if pkgbuild is empty
"""
assert not PkgbuildPrinter(Changes()).properties()
assert not PkgbuildPrinter(Changes("sha", "changes")).properties()
def test_title(pkgbuild_printer: PkgbuildPrinter) -> None:
"""
must return non-empty title
"""
assert pkgbuild_printer.title()
def test_title_empty() -> None:
"""
must return empty title if change is empty
"""
assert not PkgbuildPrinter(Changes()).title()
assert not PkgbuildPrinter(Changes("sha")).title()

View File

@@ -219,7 +219,7 @@ def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any
mocker.patch("ahriman.models.repository_paths.getpwuid", return_value=passwd) mocker.patch("ahriman.models.repository_paths.getpwuid", return_value=passwd)
mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman]) mocker.patch("ahriman.core.repository.executor.Executor.packages", return_value=[package_ahriman])
changes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get", changes_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_get",
return_value=Changes("commit", "change")) return_value=Changes("commit", "change", "pkgbuild"))
commit_sha_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update") commit_sha_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_changes_update")
depends_on_mock = mocker.patch("ahriman.core.build_tools.package_archive.PackageArchive.depends_on", depends_on_mock = mocker.patch("ahriman.core.build_tools.package_archive.PackageArchive.depends_on",
return_value=Dependencies()) return_value=Dependencies())
@@ -231,7 +231,7 @@ def test_process_build(executor: Executor, package_ahriman: Package, passwd: Any
build_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(Path, strict=True), None, None) build_mock.assert_called_once_with(package_ahriman, pytest.helpers.anyvar(Path, strict=True), None, None)
depends_on_mock.assert_called_once_with() depends_on_mock.assert_called_once_with()
dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies()) dependencies_mock.assert_called_once_with(package_ahriman.base, Dependencies())
commit_sha_mock.assert_called_once_with(package_ahriman.base, Changes("sha", "change")) commit_sha_mock.assert_called_once_with(package_ahriman.base, Changes("sha", "change", "pkgbuild"))
def test_process_build_bump_pkgrel(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None: def test_process_build_bump_pkgrel(executor: Executor, package_ahriman: Package, mocker: MockerFixture) -> None:

View File

@@ -97,7 +97,7 @@ def test_package_changes(package_info: PackageInfo, package_ahriman: Package, mo
""" """
changes = Changes("sha", "change") changes = Changes("sha", "change")
load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha2") load_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha2")
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes", return_value=changes.changes) changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes", return_value=changes)
assert package_info.package_changes(package_ahriman, changes.last_commit_sha) == changes assert package_info.package_changes(package_ahriman, changes.last_commit_sha) == changes
load_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman, [], package_info.paths) load_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman, [], package_info.paths)
@@ -108,11 +108,10 @@ def test_package_changes_skip(package_info: PackageInfo, package_ahriman: Packag
""" """
must skip loading package changes if no new commits must skip loading package changes if no new commits
""" """
changes = Changes("sha") mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha")
mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value=changes.last_commit_sha)
changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes") changes_mock = mocker.patch("ahriman.core.build_tools.sources.Sources.changes")
assert package_info.package_changes(package_ahriman, changes.last_commit_sha) == changes assert package_info.package_changes(package_ahriman, "sha") is None
changes_mock.assert_not_called() changes_mock.assert_not_called()

View File

@@ -1,17 +1,6 @@
from ahriman.models.changes import Changes from ahriman.models.changes import Changes
def test_is_empty() -> None:
"""
must check if changes are empty
"""
assert Changes().is_empty
assert Changes("sha").is_empty
assert not Changes("sha", "change").is_empty
assert not Changes(None, "change").is_empty # well, ok
def test_changes_from_json_view() -> None: def test_changes_from_json_view() -> None:
""" """
must construct same object from json must construct same object from json