mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
Compare commits
3 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca55c3d80 | ||
|
|
528976e547 | ||
|
|
1ddd210170 |
@@ -1,26 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { FlaskConical } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { GitCommitHorizontal } from "lucide-react";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
|
||||
export function LabsTab() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
const qc = useQueryClient();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const coAuthoredByEnabled =
|
||||
(workspace?.settings as Record<string, unknown>)?.co_authored_by_enabled !== false;
|
||||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
if (!workspace || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateWorkspace(workspace.id, {
|
||||
settings: {
|
||||
...((workspace.settings as Record<string, unknown>) ?? {}),
|
||||
co_authored_by_enabled: checked,
|
||||
},
|
||||
});
|
||||
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] | undefined) =>
|
||||
old?.map((ws) => (ws.id === updated.id ? updated : ws)),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Failed to update setting",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Labs</h2>
|
||||
<h2 className="text-sm font-semibold">Git</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
|
||||
<FlaskConical className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">No experimental features yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Beta features that require manual opt-in will appear here.
|
||||
</p>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-md border bg-muted/50 p-2 text-muted-foreground">
|
||||
<GitCommitHorizontal className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="co-authored-by"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Co-authored-by trailer
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically add{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
Co-authored-by: multica-agent <github@multica.ai>
|
||||
</code>{" "}
|
||||
to commits made by agents.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="co-authored-by"
|
||||
checked={coAuthoredByEnabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -295,9 +295,10 @@ func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
|
||||
|
||||
// RegisterResponse holds the server's response to a daemon registration.
|
||||
type RegisterResponse struct {
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
Repos []RepoData `json:"repos"`
|
||||
ReposVersion string `json:"repos_version"`
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
Repos []RepoData `json:"repos"`
|
||||
ReposVersion string `json:"repos_version"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req map[string]any) (*RegisterResponse, error) {
|
||||
|
||||
@@ -30,6 +30,7 @@ type workspaceState struct {
|
||||
runtimeIDs []string
|
||||
reposVersion string // stored for future use: skip refresh when version unchanged
|
||||
allowedRepoURLs map[string]struct{}
|
||||
settings json.RawMessage // workspace settings (JSONB)
|
||||
lastRepoSyncErr string
|
||||
repoRefreshMu sync.Mutex
|
||||
}
|
||||
@@ -321,12 +322,13 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData) *workspaceState {
|
||||
func newWorkspaceState(workspaceID string, runtimeIDs []string, reposVersion string, repos []RepoData, settings json.RawMessage) *workspaceState {
|
||||
return &workspaceState{
|
||||
workspaceID: workspaceID,
|
||||
runtimeIDs: runtimeIDs,
|
||||
reposVersion: reposVersion,
|
||||
allowedRepoURLs: repoAllowlist(repos),
|
||||
settings: settings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,6 +372,25 @@ func (d *Daemon) workspaceLastRepoSyncErr(workspaceID string) string {
|
||||
return ws.lastRepoSyncErr
|
||||
}
|
||||
|
||||
// workspaceCoAuthoredByEnabled returns whether the Co-authored-by hook should
|
||||
// be installed for the given workspace. Defaults to true when the setting is
|
||||
// absent (new workspaces, older servers that don't send settings).
|
||||
func (d *Daemon) workspaceCoAuthoredByEnabled(workspaceID string) bool {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
ws, ok := d.workspaces[workspaceID]
|
||||
if !ok || len(ws.settings) == 0 {
|
||||
return true // default: enabled
|
||||
}
|
||||
var s struct {
|
||||
CoAuthoredByEnabled *bool `json:"co_authored_by_enabled"`
|
||||
}
|
||||
if err := json.Unmarshal(ws.settings, &s); err != nil || s.CoAuthoredByEnabled == nil {
|
||||
return true // default: enabled
|
||||
}
|
||||
return *s.CoAuthoredByEnabled
|
||||
}
|
||||
|
||||
func (d *Daemon) syncWorkspaceRepos(workspaceID string, repos []RepoData) {
|
||||
if d.repoCache == nil {
|
||||
return
|
||||
@@ -510,7 +531,7 @@ func (d *Daemon) syncWorkspacesFromAPI(ctx context.Context) error {
|
||||
d.logger.Info("registered runtime", "workspace_id", id, "runtime_id", rt.ID, "provider", rt.Provider)
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos)
|
||||
d.workspaces[id] = newWorkspaceState(id, runtimeIDs, resp.ReposVersion, resp.Repos, resp.Settings)
|
||||
for _, rt := range resp.Runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
|
||||
@@ -554,7 +554,7 @@ func TestEnsureRepoReadyFastPathDoesNotRefresh(t *testing.T) {
|
||||
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("seed repo cache: %v", err)
|
||||
}
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
|
||||
|
||||
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
|
||||
t.Fatalf("ensureRepoReady: %v", err)
|
||||
@@ -576,7 +576,7 @@ func TestEnsureRepoReadyTrimsURL(t *testing.T) {
|
||||
if err := d.repoCache.Sync("ws-1", []repocache.RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("seed repo cache: %v", err)
|
||||
}
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "v1", []RepoData{{URL: sourceRepo}}, nil)
|
||||
|
||||
// URL with trailing whitespace should still hit the fast path.
|
||||
if err := d.ensureRepoReady(context.Background(), "ws-1", " "+sourceRepo+" "); err != nil {
|
||||
@@ -604,7 +604,7 @@ func TestEnsureRepoReadyRefreshesOnMiss(t *testing.T) {
|
||||
ReposVersion: "v2",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
if err := d.ensureRepoReady(context.Background(), "ws-1", sourceRepo); err != nil {
|
||||
t.Fatalf("ensureRepoReady: %v", err)
|
||||
@@ -627,7 +627,7 @@ func TestEnsureRepoReadyReturnsNotConfigured(t *testing.T) {
|
||||
ReposVersion: "v1",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
err := d.ensureRepoReady(context.Background(), "ws-1", "git@example.com:team/api.git")
|
||||
if !errors.Is(err, ErrRepoNotConfigured) {
|
||||
@@ -646,7 +646,7 @@ func TestEnsureRepoReadyReportsSyncFailure(t *testing.T) {
|
||||
ReposVersion: "v1",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
err := d.ensureRepoReady(context.Background(), "ws-1", missingRepo)
|
||||
if err == nil || !strings.Contains(err.Error(), "repo is configured but not synced:") {
|
||||
@@ -674,7 +674,7 @@ func TestEnsureRepoReadyConcurrentMissRefreshesOnce(t *testing.T) {
|
||||
ReposVersion: "v2",
|
||||
})
|
||||
})
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil)
|
||||
d.workspaces["ws-1"] = newWorkspaceState("ws-1", nil, "", nil, nil)
|
||||
|
||||
const concurrency = 8
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -158,11 +158,12 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
|
||||
}
|
||||
|
||||
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
RepoURL: req.URL,
|
||||
WorkDir: req.WorkDir,
|
||||
AgentName: req.AgentName,
|
||||
TaskID: req.TaskID,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
RepoURL: req.URL,
|
||||
WorkDir: req.WorkDir,
|
||||
AgentName: req.AgentName,
|
||||
TaskID: req.TaskID,
|
||||
CoAuthoredByEnabled: d.workspaceCoAuthoredByEnabled(req.WorkspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)
|
||||
|
||||
@@ -327,11 +327,12 @@ func setFetchRefspec(barePath, refspec string) error {
|
||||
|
||||
// WorktreeParams holds inputs for creating a worktree from a cached bare clone.
|
||||
type WorktreeParams struct {
|
||||
WorkspaceID string // workspace that owns the repo
|
||||
RepoURL string // remote URL to look up in the cache
|
||||
WorkDir string // parent directory for the worktree (e.g. task workdir)
|
||||
AgentName string // for branch naming
|
||||
TaskID string // for branch naming uniqueness
|
||||
WorkspaceID string // workspace that owns the repo
|
||||
RepoURL string // remote URL to look up in the cache
|
||||
WorkDir string // parent directory for the worktree (e.g. task workdir)
|
||||
AgentName string // for branch naming
|
||||
TaskID string // for branch naming uniqueness
|
||||
CoAuthoredByEnabled bool // install prepare-commit-msg hook for Co-authored-by trailer
|
||||
}
|
||||
|
||||
// WorktreeResult describes a successfully created worktree.
|
||||
@@ -401,6 +402,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
|
||||
if params.CoAuthoredByEnabled {
|
||||
if err := installCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: existing worktree updated",
|
||||
"url", params.RepoURL,
|
||||
"path", worktreePath,
|
||||
@@ -426,6 +434,13 @@ func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
// Install Co-authored-by hook for Multica Agent attribution (if enabled).
|
||||
if params.CoAuthoredByEnabled {
|
||||
if err := installCoAuthoredByHook(worktreePath); err != nil {
|
||||
c.logger.Warn("repo checkout: install co-authored-by hook failed (non-fatal)", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: worktree created",
|
||||
"url", params.RepoURL,
|
||||
"path", worktreePath,
|
||||
@@ -650,6 +665,58 @@ func bareHeadBranch(barePath string) string {
|
||||
return ref
|
||||
}
|
||||
|
||||
// prepareCommitMsgHook is the prepare-commit-msg hook script that appends a
|
||||
// Co-authored-by trailer for the Multica Agent to every commit message.
|
||||
const prepareCommitMsgHook = `#!/bin/sh
|
||||
# Multica: add Co-authored-by trailer for the Multica Agent.
|
||||
# Installed by the Multica daemon. Do not edit — it will be overwritten.
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_SOURCE="$2"
|
||||
|
||||
# Skip merge and squash commits.
|
||||
case "$COMMIT_SOURCE" in
|
||||
merge|squash) exit 0 ;;
|
||||
esac
|
||||
|
||||
TRAILER="Co-authored-by: multica-agent <github@multica.ai>"
|
||||
|
||||
# Don't add if already present.
|
||||
if grep -qF "$TRAILER" "$COMMIT_MSG_FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use git interpret-trailers for proper formatting.
|
||||
git interpret-trailers --in-place --trailer "$TRAILER" "$COMMIT_MSG_FILE"
|
||||
`
|
||||
|
||||
// installCoAuthoredByHook installs a prepare-commit-msg git hook that appends
|
||||
// a Co-authored-by trailer for the Multica Agent. The hook is installed in the
|
||||
// git common directory (the bare repo for worktrees) so it applies to all
|
||||
// worktrees created from this cache.
|
||||
func installCoAuthoredByHook(worktreePath string) error {
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-common-dir")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve git common dir: %w", err)
|
||||
}
|
||||
commonDir := strings.TrimSpace(string(out))
|
||||
if !filepath.IsAbs(commonDir) {
|
||||
commonDir = filepath.Join(worktreePath, commonDir)
|
||||
}
|
||||
|
||||
hooksDir := filepath.Join(commonDir, "hooks")
|
||||
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create hooks dir: %w", err)
|
||||
}
|
||||
|
||||
hookPath := filepath.Join(hooksDir, "prepare-commit-msg")
|
||||
if err := os.WriteFile(hookPath, []byte(prepareCommitMsgHook), 0o755); err != nil {
|
||||
return fmt.Errorf("write prepare-commit-msg hook: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
|
||||
func excludeFromGit(worktreePath, pattern string) error {
|
||||
cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir")
|
||||
|
||||
@@ -883,6 +883,97 @@ func TestGetRemoteDefaultBranchUsesBareHeadHintForCustomDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateWorktreeInstallsCoAuthoredByHook verifies that CreateWorktree
|
||||
// installs a prepare-commit-msg hook that appends a Co-authored-by trailer
|
||||
// for the Multica Agent to every commit made in the worktree.
|
||||
func TestCreateWorktreeInstallsCoAuthoredByHook(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "a1b2c3d4-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree failed: %v", err)
|
||||
}
|
||||
|
||||
// Make a commit in the worktree and verify the hook appends the trailer.
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
runGitAuthored(t, result.Path, "add", ".")
|
||||
runGitAuthored(t, result.Path, "commit", "-m", "test commit")
|
||||
|
||||
// Read the commit message.
|
||||
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git log failed: %v", err)
|
||||
}
|
||||
commitMsg := string(out)
|
||||
expectedTrailer := "Co-authored-by: multica-agent <github@multica.ai>"
|
||||
if !strings.Contains(commitMsg, expectedTrailer) {
|
||||
t.Errorf("commit message missing Co-authored-by trailer.\ngot:\n%s", commitMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCoAuthoredByHookIdempotent verifies that the hook does not add a
|
||||
// duplicate Co-authored-by trailer if one is already present in the message.
|
||||
func TestCoAuthoredByHookIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Test Agent",
|
||||
TaskID: "b2c3d4e5-0000-0000-0000-000000000000",
|
||||
CoAuthoredByEnabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree failed: %v", err)
|
||||
}
|
||||
|
||||
// Commit with the trailer already in the message.
|
||||
trailer := "Co-authored-by: multica-agent <github@multica.ai>"
|
||||
if err := os.WriteFile(filepath.Join(result.Path, "test.txt"), []byte("hello\n"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
runGitAuthored(t, result.Path, "add", ".")
|
||||
runGitAuthored(t, result.Path, "commit", "-m", "test commit\n\n"+trailer)
|
||||
|
||||
out, err := exec.Command("git", "-C", result.Path, "log", "-1", "--format=%B").Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git log failed: %v", err)
|
||||
}
|
||||
commitMsg := string(out)
|
||||
|
||||
// Count occurrences — should appear exactly once.
|
||||
count := strings.Count(commitMsg, trailer)
|
||||
if count != 1 {
|
||||
t.Errorf("expected exactly 1 Co-authored-by trailer, found %d.\ngot:\n%s", count, commitMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRemoteDefaultBranchAmbiguousOriginReturnsEmpty verifies step 4's
|
||||
// safe-scan gating: when the cache has multiple refs/remotes/origin/*
|
||||
// entries, none match the common defaults, and none match the bare HEAD
|
||||
|
||||
@@ -342,10 +342,19 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
|
||||
repoResp := workspaceReposResponse(req.WorkspaceID, ws.Repos)
|
||||
|
||||
// Include workspace settings so the daemon can honour feature toggles
|
||||
// (e.g. co_authored_by_enabled for the prepare-commit-msg hook).
|
||||
var settings json.RawMessage
|
||||
if len(ws.Settings) > 0 {
|
||||
settings = json.RawMessage(ws.Settings)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"runtimes": resp,
|
||||
"repos": repoResp.Repos,
|
||||
"repos_version": repoResp.ReposVersion,
|
||||
"settings": settings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user