Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/Services/System/DefaultProcessRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace WinHome.Services.System
{
public class DefaultProcessRunner
{
public async Task<int> RunProcessAsync(string fileName, string arguments, TimeSpan? customTimeout = null)
{
TimeSpan timeout = customTimeout ?? TimeSpan.FromMinutes(10); // Configurable, defaults to 10 mins

var processStartInfo = new ProcessStartInfo
{
FileName = fileName,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};

using (var process = new Process { StartInfo = processStartInfo })
{
process.Start();

// Setup cancellation token for timeout
using (var cts = new CancellationTokenSource(timeout))
{
try
{
// Periodically report status while waiting for the process to exit
var statusTask = Task.Run(async () =>
{
Stopwatch sw = Stopwatch.StartNew();
while (!process.HasExited && !cts.IsCancellationRequested)
{
await Task.Delay(5000, cts.Token); // Log every 5 seconds
Console.WriteLine($"[ProcessRunner] '{fileName}' is still running... (Elapsed: {sw.Elapsed.ToString(@"mm\:ss")})");
}
}, cts.Token);

// Wait for the process to complete or timeout
await process.WaitForExitAsync(cts.Token);
return process.ExitCode;
}
catch (OperationCanceledException)
{
// Timeout reached, kill process gracefully
Console.WriteLine($"[ProcessRunner] Error: Process '{fileName}' exceeded the timeout of {timeout.TotalMinutes} minutes and was terminated.");
if (!process.HasExited)
{
process.Kill(true);
}
throw new TimeoutException($"Process '{fileName}' timed out after {timeout.TotalMinutes} minutes.");
}
}
}
}
}
}
49 changes: 49 additions & 0 deletions src/Services/System/GitService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;

namespace WinHome.Services.System
{
public class GitService
{
private static readonly HashSet<string> SensitiveKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"user.signingkey",
"http.extraheader",
"core.askpass",
"credential.helper"
};

public void SetGlobalConfig(Dictionary<string, string> gitConfigs)
{
if (gitConfigs == null) return;

foreach (var config in gitConfigs)
{
string key = config.Key;
string value = config.Value;

// Mask sensitive information in logs
string displayValue = IsSensitiveKey(key) ? "********" : value;

Console.WriteLine($"[Git] Setting {key} = {displayValue}...");

// Normally we would execute the git command here
// e.g., ExecuteGitCommand($"config --global {key} \"{value}\"");
}
}

private bool IsSensitiveKey(string key)
{
if (SensitiveKeys.Contains(key)) return true;

// Catch-all for any other keys that might contain sensitive data like tokens
string lowerKey = key.ToLowerInvariant();
if (lowerKey.Contains("token") || lowerKey.Contains("secret") || lowerKey.Contains("password") || lowerKey.Contains("auth"))
{
return true;
}

return false;
}
}
}
56 changes: 56 additions & 0 deletions src/Services/System/ScheduledTaskService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.IO;

namespace WinHome.Services.System
{
public class ScheduledTaskService
{
public void Apply(TaskConfig task)
{
foreach (var actionConfig in task.Actions)
{
if (actionConfig.Type == "exec")
{
// Fix for #2750: Validate executable path
if (string.IsNullOrWhiteSpace(actionConfig.Path))
{
throw new ArgumentException("Executable path cannot be null or empty.");
}

if (!File.Exists(actionConfig.Path))
{
throw new FileNotFoundException($"Executable path does not exist: {actionConfig.Path}");
}

// Security check: reject shell interpreters to prevent command injection
string fileName = Path.GetFileName(actionConfig.Path).ToLower();
if (fileName == "cmd.exe" || fileName == "powershell.exe" || fileName == "wscript.exe" || fileName == "cscript.exe")
{
throw new UnauthorizedAccessException($"Task execution using shell interpreter '{fileName}' is not allowed due to security risks.");
}

var action = new ExecAction(actionConfig.Path, actionConfig.Arguments, actionConfig.WorkingDirectory);
// Continue with task registration...
}
}
}
}

public class TaskConfig
{
public ActionConfig[] Actions { get; set; }
}

public class ActionConfig
{
public string Type { get; set; }
public string Path { get; set; }
public string Arguments { get; set; }
public string WorkingDirectory { get; set; }
}

public class ExecAction
{
public ExecAction(string path, string arguments, string workingDirectory) { }
}
}
55 changes: 54 additions & 1 deletion src/app/api/metrics/weekly-summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ interface WeeklySummaryData {
thisWeek: { opened: number; merged: number };
lastWeek: { opened: number; merged: number };
};
issues: {
thisWeek: number;
lastWeek: number;
};
activeDays: {
thisWeek: number;
lastWeek: number;
Expand Down Expand Up @@ -225,7 +229,45 @@ async function fetchWeeklySummaryForAccount(
}
}

// Search API calls 3+ — fetchActiveDates pages through to build the 90-day commit date set.
// Search API call 3 of 4 — fetches issues closed in the past 14 days.
const issuesRes = await fetch(
`${GITHUB_API}/search/issues?q=type:issue+author:@me+is:closed+closed:>=${fourteenDaysAgoStr}&per_page=100`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
}
);

if (!issuesRes.ok) {
if (issuesRes.status === 401) throw new GitHubAuthError();
throw new Error("GitHub API error");
}

const issuesData = (await issuesRes.json()) as {
items: Array<{
closed_at: string | null;
}>;
};

let issuesClosedThisWeek = 0;
let issuesClosedLastWeek = 0;

for (const item of issuesData.items) {
if (!item.closed_at) continue;
const closedAt = new Date(item.closed_at);
if (Number.isNaN(closedAt.getTime())) continue;

if (closedAt >= currentWeekStart) {
issuesClosedThisWeek++;
} else if (closedAt >= prevWeekStart && closedAt <= prevWeekEnd) {
issuesClosedLastWeek++;
}
}

// Search API calls 4+ — fetchActiveDates pages through to build the 90-day commit date set.
const streakDates = await fetchActiveDates(githubLogin, token);
const commitDelta = commitsThisWeek - commitsPrevWeek;

Expand All @@ -240,6 +282,10 @@ async function fetchWeeklySummaryForAccount(
thisWeek: { opened: prsOpenedThisWeek, merged: prsMergedThisWeek },
lastWeek: { opened: prsOpenedLastWeek, merged: prsMergedLastWeek },
},
issues: {
thisWeek: issuesClosedThisWeek,
lastWeek: issuesClosedLastWeek,
},
activeDays: {
thisWeek: activeDaysThisWeek.size,
lastWeek: activeDaysLastWeek.size,
Expand Down Expand Up @@ -314,6 +360,9 @@ export async function GET(req: NextRequest) {
const prsLastWeekOpened = results.reduce((sum, r) => sum + r.prs.lastWeek.opened, 0);
const prsLastWeekMerged = results.reduce((sum, r) => sum + r.prs.lastWeek.merged, 0);

const issuesThisWeek = results.reduce((sum, r) => sum + r.issues.thisWeek, 0);
const issuesLastWeek = results.reduce((sum, r) => sum + r.issues.lastWeek, 0);

const activeDaysThisWeek = Math.min(7, results.reduce((sum, r) => sum + r.activeDays.thisWeek, 0));
const activeDaysLastWeek = Math.min(7, results.reduce((sum, r) => sum + r.activeDays.lastWeek, 0));

Expand All @@ -340,6 +389,10 @@ export async function GET(req: NextRequest) {
thisWeek: { opened: prsThisWeekOpened, merged: prsThisWeekMerged },
lastWeek: { opened: prsLastWeekOpened, merged: prsLastWeekMerged },
},
issues: {
thisWeek: issuesThisWeek,
lastWeek: issuesLastWeek,
},
activeDays: {
thisWeek: activeDaysThisWeek,
lastWeek: activeDaysLastWeek,
Expand Down
15 changes: 15 additions & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import DashboardSSEProvider from "@/components/DashboardSSEProvider";
import { DashboardWidgetA11yProvider } from "@/components/dashboard/DashboardWidgetA11yContext";
import RoastHypeWidget from "./RoastHypeWidget";

import LazyWidget from "@/components/LazyWidget";
import { SkeletonCard } from "@/components/dashboard/CustomizableDashboard";
import dynamic from "next/dynamic";

const WeeklyCodingInsightsCard = dynamic(
() => import("@/components/WeeklyCodingInsightsCard"),
{ loading: () => <SkeletonCard /> },
);
export default async function DashboardPage() {
// In the production standalone Playwright build, getServerSession can fail to
// read the test JWT cookie. Decode the cookie directly as a fallback so that
Expand Down Expand Up @@ -92,6 +100,13 @@ export default async function DashboardPage() {
<TodayFocusHero userName={session.user?.name ?? null} />
</section>

{/* Weekly Coding Insights */}
<section data-export-id="weekly-coding-insights">
<LazyWidget fallback={<SkeletonCard />}>
<WeeklyCodingInsightsCard />
</LazyWidget>
</section>

{/* Featured Section */}
<section>
<div className="relative overflow-hidden rounded-xl border border-[var(--border)] bg-gradient-to-r from-violet-950/20 via-indigo-950/10 to-transparent p-8 shadow-lg hover:shadow-xl transition-shadow flex flex-col md:flex-row justify-between items-start md:items-center gap-8">
Expand Down
Loading
Loading