Compare commits

...

4 Commits

49 changed files with 516 additions and 212 deletions

View File

@@ -425,6 +425,15 @@ The PKGBUILD class also provides some additional functions on top of that:
* Ability to extract fields defined inside ``package*()`` functions, which are in particular used for the multi-packages.
* Shell substitution, which supports constructions ``$var`` (including ``${var}``), ``${var#(#)pattern}``, ``${var%(%)pattern}`` and ``${var/(/)pattern/replacement}`` (including ``#pattern`` and ``%pattern``).
HTTP client
^^^^^^^^^^^
The ``ahriman.core.http`` package provides a HTTP client built on top of the ``requests`` library.
The base class ``ahriman.core.http.SyncHttpClient`` wraps ``requests.Session`` and provides common features for all HTTP interactions: configurable timeouts, retry policies with exponential backoff (using ``urllib3.util.retry.Retry``), basic authentication, custom User-Agent header, error processing, and ``make_request`` method. The session is lazily created (via ``cached_property``).
On top of that, ``ahriman.core.http.SyncAhrimanClient`` extends the base client for communication with the ahriman web service specifically. It adds automatic login on session creation (using configured credentials), ``X-Request-ID`` header injection and Unix socket transport support (via ``requests-unixsocket2``) if required.
Additional features
^^^^^^^^^^^^^^^^^^^

View File

@@ -2,7 +2,7 @@
"name": "ahriman-frontend",
"private": true,
"type": "module",
"version": "2.20.0-rc7",
"version": "2.20.0",
"scripts": {
"build": "tsc && vite build",
"dev": "vite",

View File

@@ -17,16 +17,14 @@
* 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 CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider } from "@mui/material/styles";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AppLayout from "components/layout/AppLayout";
import { AuthProvider } from "contexts/AuthProvider";
import { ClientProvider } from "contexts/ClientProvider";
import { NotificationProvider } from "contexts/NotificationProvider";
import { RepositoryProvider } from "contexts/RepositoryProvider";
import { ThemeProvider } from "contexts/ThemeProvider";
import type React from "react";
import Theme from "theme/Theme";
const queryClient = new QueryClient({
defaultOptions: {
@@ -39,8 +37,7 @@ const queryClient = new QueryClient({
export default function App(): React.JSX.Element {
return <QueryClientProvider client={queryClient}>
<ThemeProvider theme={Theme}>
<CssBaseline />
<ThemeProvider>
<NotificationProvider>
<ClientProvider>
<AuthProvider>

View File

@@ -17,6 +17,7 @@
* 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 { blue } from "@mui/material/colors";
import type { Event } from "models/Event";
import type React from "react";
import { Line } from "react-chartjs-2";
@@ -33,6 +34,8 @@ export default function EventDurationLineChart({ events }: EventDurationLineChar
{
label: "update duration, s",
data: updateEvents.map(event => event.data?.took ?? 0),
borderColor: blue[500],
backgroundColor: blue[200],
cubicInterpolationMode: "monotone" as const,
tension: 0.4,
},

View File

@@ -47,6 +47,7 @@ export default function AutoRefreshControl({
<Tooltip title="Auto-refresh">
<IconButton
size="small"
aria-label="Auto-refresh"
onClick={event => setAnchorEl(event.currentTarget)}
color={enabled ? "primary" : "default"}
>

View File

@@ -17,47 +17,57 @@
* 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 { Box } from "@mui/material";
import "components/common/syntaxLanguages";
import { Box, useTheme } from "@mui/material";
import CopyButton from "components/common/CopyButton";
import { useThemeMode } from "hooks/useThemeMode";
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 {
preRef?: RefObject<HTMLElement | null>;
getText: () => string;
content: string;
height?: number | string;
language?: string;
onScroll?: () => void;
wordBreak?: boolean;
preRef?: RefObject<HTMLElement | null>;
}
export default function CodeBlock({
preRef,
getText,
content,
height,
language = "text",
onScroll,
wordBreak,
preRef,
}: CodeBlockProps): React.JSX.Element {
const { mode } = useThemeMode();
const theme = useTheme();
return <Box sx={{ position: "relative" }}>
<Box
ref={preRef}
component="pre"
onScroll={onScroll}
sx={{
backgroundColor: "grey.100",
p: 2,
borderRadius: 1,
overflow: "auto",
height,
sx={{ overflow: "auto", height }}
>
<SyntaxHighlighter
language={language}
style={mode === "dark" ? vs2015 : githubGist}
wrapLongLines
customStyle={{
padding: theme.spacing(2),
borderRadius: `${theme.shape.borderRadius}px`,
fontSize: "0.8rem",
fontFamily: "monospace",
...wordBreak ? { whiteSpace: "pre-wrap", wordBreak: "break-all" } : {},
margin: 0,
minHeight: "100%",
}}
>
<code>
{getText()}
</code>
</Box>
<Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton getText={getText} />
{content}
</SyntaxHighlighter>
</Box>
{content && <Box sx={{ position: "absolute", top: 8, right: 8 }}>
<CopyButton text={content} />
</Box>}
</Box>;
}

View File

@@ -23,17 +23,17 @@ import { IconButton, Tooltip } from "@mui/material";
import React, { useEffect, useRef, useState } from "react";
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 timer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timer.current), []);
const handleCopy: () => Promise<void> = async () => {
await navigator.clipboard.writeText(getText());
await navigator.clipboard.writeText(text);
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 2000);

View File

@@ -25,12 +25,12 @@ import type React from "react";
export default function RepositorySelect({
repositorySelect,
}: { repositorySelect: SelectedRepositoryResult }): React.JSX.Element {
const { repositories, current } = useRepository();
const { repositories, currentRepository } = useRepository();
return <FormControl fullWidth margin="normal">
<InputLabel>repository</InputLabel>
<Select
value={repositorySelect.selectedKey || (current?.key ?? "")}
value={repositorySelect.selectedKey || (currentRepository?.key ?? "")}
label="repository"
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 {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { data: status } = useQuery<InternalStatus>({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
enabled: open,
});

View File

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

View File

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

View File

@@ -17,7 +17,9 @@
* 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 { Box, Container } from "@mui/material";
import Brightness4Icon from "@mui/icons-material/Brightness4";
import Brightness7Icon from "@mui/icons-material/Brightness7";
import { Box, Container, IconButton, Tooltip } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import LoginDialog from "components/dialogs/LoginDialog";
import Footer from "components/layout/Footer";
@@ -27,6 +29,7 @@ import { QueryKeys } from "hooks/QueryKeys";
import { useAuth } from "hooks/useAuth";
import { useClient } from "hooks/useClient";
import { useRepository } from "hooks/useRepository";
import { useThemeMode } from "hooks/useThemeMode";
import type { InfoResponse } from "models/InfoResponse";
import React, { useEffect, useState } from "react";
@@ -34,6 +37,7 @@ export default function AppLayout(): React.JSX.Element {
const client = useClient();
const { setAuthState } = useAuth();
const { setRepositories } = useRepository();
const { mode, toggleTheme } = useThemeMode();
const [loginOpen, setLoginOpen] = useState(false);
const { data: info } = useQuery<InfoResponse>({
@@ -58,6 +62,11 @@ export default function AppLayout(): React.JSX.Element {
<Box sx={{ flex: 1 }}>
<Navbar />
</Box>
<Tooltip title="Toggle theme">
<IconButton aria-label="Toggle theme" onClick={toggleTheme}>
{mode === "dark" ? <Brightness7Icon /> : <Brightness4Icon />}
</IconButton>
</Tooltip>
</Box>
<PackageTable

View File

@@ -22,14 +22,15 @@ import { useRepository } from "hooks/useRepository";
import type React from "react";
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;
}
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" }}>
@@ -38,7 +39,7 @@ export default function Navbar(): React.JSX.Element | null {
onChange={(_, newValue: number) => {
const repository = repositories[newValue];
if (repository) {
setCurrent(repository);
setCurrentRepository(repository);
}
}}
variant="scrollable"

View File

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

View File

@@ -17,19 +17,10 @@
* 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 { Box } from "@mui/material";
import { useQuery } from "@tanstack/react-query";
import CopyButton from "components/common/CopyButton";
import { QueryKeys } from "hooks/QueryKeys";
import { useClient } from "hooks/useClient";
import type { Changes } from "models/Changes";
import CodeBlock from "components/common/CodeBlock";
import { usePackageChanges } from "hooks/usePackageChanges";
import type { RepositoryId } from "models/RepositoryId";
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 } from "react-syntax-highlighter/dist/esm/styles/hljs";
SyntaxHighlighter.registerLanguage("diff", diff);
interface ChangesTabProps {
packageBase: string;
@@ -37,34 +28,7 @@ interface ChangesTabProps {
}
export default function ChangesTab({ packageBase, repository }: ChangesTabProps): React.JSX.Element {
const client = useClient();
const data = usePackageChanges(packageBase, repository);
const { data } = useQuery<Changes>({
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={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>;
return <CodeBlock language="diff" content={data?.changes ?? ""} height={400} />;
}

View File

@@ -55,7 +55,7 @@ export default function PackagePatchesList({
sx={{ flex: 1 }}
/>
{editable &&
<IconButton size="small" color="error" onClick={() => onDelete(patch.key)}>
<IconButton size="small" color="error" aria-label="Remove patch" onClick={() => onDelete(patch.key)}>
<DeleteIcon fontSize="small" />
</IconButton>
}

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 {
repositories: RepositoryId[];
current: RepositoryId | null;
currentRepository: RepositoryId | null;
setRepositories: (repositories: RepositoryId[]) => void;
setCurrent: (repository: RepositoryId) => void;
setCurrentRepository: (repository: RepositoryId) => void;
}
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 hash = useSyncExternalStore(subscribeToHash, getHashSnapshot);
const current = useMemo(() => {
const currentRepository = useMemo(() => {
if (repositories.length === 0) {
return null;
}
return repositories.find(repository => repository.key === hash) ?? repositories[0] ?? null;
}, [repositories, hash]);
const setCurrent = useCallback((repository: RepositoryId) => {
const setCurrentRepository = useCallback((repository: RepositoryId) => {
window.location.hash = repository.key;
}, []);
const value = useMemo(() => ({
repositories, current, setRepositories, setCurrent,
}), [repositories, current, setCurrent]);
repositories, currentRepository, setRepositories, setCurrentRepository,
}), [repositories, currentRepository, setCurrentRepository]);
return <RepositoryContext.Provider value={value}>{children}</RepositoryContext.Provider>;
}

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 { createContext } from "react";
export interface ThemeContextValue {
mode: "light" | "dark";
toggleTheme: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);

View File

@@ -0,0 +1,56 @@
/*
* 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 CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider as MuiThemeProvider } from "@mui/material/styles";
import { defaults as chartDefaults } from "chart.js";
import { ThemeContext } from "contexts/ThemeContext";
import { useLocalStorage } from "hooks/useLocalStorage";
import React, { useCallback, useEffect, useMemo } from "react";
import { createAppTheme } from "theme/Theme";
function systemPreference(): "light" | "dark" {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [mode, setMode] = useLocalStorage<"light" | "dark">("theme-mode", systemPreference());
const toggleTheme = useCallback(() => {
setMode(prev => prev === "light" ? "dark" : "light");
}, [setMode]);
const theme = useMemo(() => createAppTheme(mode), [mode]);
useEffect(() => {
const textColor = mode === "dark" ? "rgba(255,255,255,0.7)" : "rgba(0,0,0,0.7)";
const gridColor = mode === "dark" ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
chartDefaults.color = textColor;
chartDefaults.borderColor = gridColor;
}, [mode]);
const value = useMemo(() => ({ mode, toggleTheme }), [mode, toggleTheme]);
return <ThemeContext.Provider value={value}>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{children}
</MuiThemeProvider>
</ThemeContext.Provider>;
}

View File

@@ -37,7 +37,7 @@ export function usePackageActions(
setSelectionModel: (model: string[]) => void,
): UsePackageActionsResult {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { showSuccess, showError } = useNotification();
const queryClient = useQueryClient();
@@ -50,13 +50,13 @@ export function usePackageActions(
action: (repository: RepositoryId) => Promise<string>,
errorMessage: string,
): Promise<void> => {
if (!current) {
if (!currentRepository) {
return;
}
try {
const successMessage = await action(current);
const successMessage = await action(currentRepository);
showSuccess("Success", successMessage);
invalidate(current);
invalidate(currentRepository);
setSelectionModel([]);
} catch (exception) {
showError("Action failed", `${errorMessage}: ${ApiError.errorDetail(exception)}`);
@@ -64,8 +64,8 @@ export function usePackageActions(
};
const handleReload: () => void = () => {
if (current !== null) {
invalidate(current);
if (currentRepository !== null) {
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 {
const client = useClient();
const { current } = useRepository();
const { currentRepository } = useRepository();
const { isAuthorized } = useAuth();
const autoRefresh = useAutoRefresh("table-autoreload-button", defaultInterval(autoRefreshIntervals));
const { data: packages = [], isLoading } = useQuery({
queryKey: current ? QueryKeys.packages(current) : ["packages"],
queryFn: current ? () => client.fetch.fetchPackages(current) : skipToken,
queryKey: currentRepository ? QueryKeys.packages(currentRepository) : ["packages"],
queryFn: currentRepository ? () => client.fetch.fetchPackages(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});
const { data: status } = useQuery({
queryKey: current ? QueryKeys.status(current) : ["status"],
queryFn: current ? () => client.fetch.fetchServerStatus(current) : skipToken,
queryKey: currentRepository ? QueryKeys.status(currentRepository) : ["status"],
queryFn: currentRepository ? () => client.fetch.fetchServerStatus(currentRepository) : skipToken,
refetchInterval: autoRefresh.interval > 0 ? autoRefresh.interval : false,
});

View File

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

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/>.
*/
import { ThemeContext, type ThemeContextValue } from "contexts/ThemeContext";
import { useContextNotNull } from "hooks/useContextNotNull";
export function useThemeMode(): ThemeContextValue {
return useContextNotNull(ThemeContext);
}

View File

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

View File

@@ -21,15 +21,15 @@ import { amber, green, grey, orange, red } from "@mui/material/colors";
import type { BuildStatus } from "models/BuildStatus";
const base: Record<BuildStatus, string> = {
unknown: grey[800],
pending: amber[900],
building: orange[900],
failed: red[900],
success: green[800],
unknown: grey[600],
pending: amber[700],
building: orange[800],
failed: red[700],
success: green[700],
};
const headerBase: Record<BuildStatus, string> = {
unknown: grey[800],
unknown: grey[600],
pending: amber[700],
building: orange[600],
failed: red[500],

View File

@@ -17,9 +17,13 @@
* 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 { createTheme } from "@mui/material/styles";
import { createTheme, type Theme } from "@mui/material/styles";
const Theme = createTheme({
export function createAppTheme(mode: "light" | "dark"): Theme {
return createTheme({
palette: {
mode,
},
components: {
MuiDialog: {
defaultProps: {
@@ -29,5 +33,4 @@ const Theme = createTheme({
},
},
});
export default Theme;
}

View File

@@ -2,7 +2,7 @@
pkgbase='ahriman'
pkgname=('ahriman' 'ahriman-core' 'ahriman-triggers' 'ahriman-web')
pkgver=2.20.0rc7
pkgver=2.20.0
pkgrel=1
pkgdesc="ArcH linux ReposItory MANager"
arch=('any')

View File

@@ -1,4 +1,4 @@
.TH AHRIMAN "1" "2026\-03\-08" "ahriman 2.20.0rc7" "ArcH linux ReposItory MANager"
.TH AHRIMAN "1" "2026\-03\-08" "ahriman 2.20.0" "ArcH linux ReposItory MANager"
.SH NAME
ahriman \- ArcH linux ReposItory MANager
.SH SYNOPSIS

View File

@@ -17,4 +17,4 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
__version__ = "2.20.0rc7"
__version__ = "2.20.0"

View File

@@ -45,7 +45,7 @@ class ApplicationRepository(ApplicationProperties):
if last_commit_sha is None:
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)
def clean(self, *, cache: bool, chroot: bool, manual: bool, packages: bool, pacman: bool) -> None:

View File

@@ -26,6 +26,7 @@ from typing import ClassVar
from ahriman.core.exceptions import CalledProcessError
from ahriman.core.log import LazyLogging
from ahriman.core.utils import check_output, utcnow, walk
from ahriman.models.changes import Changes
from ahriman.models.package import Package
from ahriman.models.pkgbuild import Pkgbuild
from ahriman.models.pkgbuild_patch import PkgbuildPatch
@@ -51,24 +52,25 @@ class Sources(LazyLogging):
}
@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
Args:
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:
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()
diff = None
if instance.fetch_until(source_dir, commit_sha=last_commit_sha) is not None:
return instance.diff(source_dir, last_commit_sha)
return None
diff = instance.diff(source_dir, last_commit_sha)
pkgbuild = instance.read(source_dir, "HEAD", Path("PKGBUILD"))
return Changes(last_commit_sha, diff, pkgbuild)
@staticmethod
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)
else:
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:
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(
"""
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
""",
{
@@ -77,16 +77,17 @@ class ChangesOperations(Operations):
connection.execute(
"""
insert into package_changes
(package_base, last_commit_sha, changes, repository)
(package_base, last_commit_sha, changes, pkgbuild, repository)
values
(:package_base, :last_commit_sha, :changes ,:repository)
(:package_base, :last_commit_sha, :changes, :pkgbuild, :repository)
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,
"last_commit_sha": changes.last_commit_sha,
"changes": changes.changes,
"pkgbuild": changes.pkgbuild,
"repository": repository_id.id,
})

View File

@@ -204,7 +204,8 @@ class Executor(PackageInfo, Cleaner):
# update commit hash for changes keeping current diff if there is any
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
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)
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
Args:
package(Package): package properties
last_commit_sha(str | None): last known commit hash
last_commit_sha(str): last known commit hash
Returns:
Changes: changes if available
Changes | None: changes if available
"""
with TemporaryDirectory(ignore_cleanup_errors=True) as dir_name:
dir_path = Path(dir_name)
patches = self.reporter.package_patches_get(package.base, None)
current_commit_sha = Sources.load(dir_path, package, patches, self.paths)
changes: str | None = None
if current_commit_sha != last_commit_sha:
changes = Sources.changes(dir_path, last_commit_sha)
return Changes(last_commit_sha, changes)
return Sources.changes(dir_path, last_commit_sha)
return None
def packages(self, filter_packages: Iterable[str] | None = None) -> list[Package]:
"""

View File

@@ -31,10 +31,12 @@ class Changes:
Attributes:
last_commit_sha(str | None): last commit hash
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
changes: str | None = None
pkgbuild: str | None = None
@property
def is_empty(self) -> bool:

View File

@@ -32,3 +32,6 @@ class ChangesSchema(Schema):
"description": "Last recorded commit hash",
"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()
last_commit_sha = data.get("last_commit_sha") # empty/null meant removal
change = data.get("changes")
pkgbuild = data.get("pkgbuild")
except Exception as 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)
raise HTTPNoContent

View File

@@ -18,7 +18,7 @@ def test_changes(application_repository: ApplicationRepository, package_ahriman:
"""
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)
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")
@@ -42,6 +42,20 @@ def test_changes_skip(application_repository: ApplicationRepository, package_ahr
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:
"""
must clean cache directory

View File

@@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
from ahriman.core.build_tools.sources import Sources
from ahriman.core.exceptions import CalledProcessError
from ahriman.models.changes import Changes
from ahriman.models.package import Package
from ahriman.models.package_source import PackageSource
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")
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")
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)
diff_mock.assert_called_once_with(local, last_commit_sha)
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()
read_mock.assert_called_once_with(local, "HEAD", Path("PKGBUILD"))
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)
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()
read_mock.assert_called_once_with(Path("local"), "HEAD", Path("PKGBUILD"))
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)
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
"""
database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
assert database.changes_get(package_ahriman.base).changes == "change1"
changes1 = Changes("sha1", "change1", "pkgbuild1")
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"),
RepositoryId("i686", database._repository_id.name))
assert database.changes_get(package_ahriman.base).changes == "change1"
assert database.changes_get(
package_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
assert database.changes_get(package_ahriman.base) == changes1
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2
def test_changes_insert_remove(database: SQLite, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must remove changes for the package
"""
database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
changes3 = Changes("sha3", "change3", "pkgbuild3")
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_python_schedule.base, changes3)
database.changes_insert(package_ahriman.base, Changes("sha2", "change2", "pkgbuild2"),
RepositoryId("i686", database._repository_id.name))
database.changes_remove(package_ahriman.base)
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
database.changes_insert(package_ahriman.base, Changes(), RepositoryId("i686", database._repository_id.name))
assert database.changes_get(
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,
@@ -43,13 +44,12 @@ def test_changes_insert_remove_full(database: SQLite, package_ahriman: Package,
"""
must remove all changes for the repository
"""
database.changes_insert(package_ahriman.base, Changes("sha1", "change1"))
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3"))
database.changes_insert(package_ahriman.base, Changes("sha2", "change2"),
RepositoryId("i686", database._repository_id.name))
changes2 = Changes("sha2", "change2", "pkgbuild2")
database.changes_insert(package_ahriman.base, Changes("sha1", "change1", "pkgbuild1"))
database.changes_insert(package_python_schedule.base, Changes("sha3", "change3", "pkgbuild3"))
database.changes_insert(package_ahriman.base, changes2, RepositoryId("i686", database._repository_id.name))
database.changes_remove(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_ahriman.base, RepositoryId("i686", database._repository_id.name)).changes == "change2"
assert database.changes_get(package_ahriman.base, RepositoryId("i686", database._repository_id.name)) == changes2

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.core.repository.executor.Executor.packages", return_value=[package_ahriman])
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")
depends_on_mock = mocker.patch("ahriman.core.build_tools.package_archive.PackageArchive.depends_on",
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)
depends_on_mock.assert_called_once_with()
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:

View File

@@ -97,7 +97,7 @@ def test_package_changes(package_info: PackageInfo, package_ahriman: Package, mo
"""
changes = Changes("sha", "change")
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
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
"""
changes = Changes("sha")
mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value=changes.last_commit_sha)
mocker.patch("ahriman.core.build_tools.sources.Sources.load", return_value="sha")
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()