Skip to content
Merged
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
41 changes: 41 additions & 0 deletions .github/workflows/hourly-jobs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Hourly jobs

# Vercel Hobby only runs crons once a day, so hourly jobs are triggered from here.
# Requires repository secrets APP_URL (e.g. https://devtrack.example.com) and CRON_SECRET.
on:
schedule:
- cron: "5 * * * *"
workflow_dispatch:

permissions: {}

concurrency:
group: hourly-jobs
cancel-in-progress: false

jobs:
trigger:
if: github.repository == 'Priyanshu-byte-coder/devtrack'
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
path:
- /api/cron/goals-sync
- /api/notifications/weekly
steps:
- name: Call ${{ matrix.path }}
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
run: |
if [ -z "$APP_URL" ] || [ -z "$CRON_SECRET" ]; then
echo "::notice::APP_URL or CRON_SECRET is not configured; skipping."
exit 0
fi
status=$(curl -sS -o response.json -w '%{http_code}' --max-time 310 \
-H "Authorization: Bearer $CRON_SECRET" "${APP_URL%/}${{ matrix.path }}")
echo "HTTP $status"
head -c 500 response.json; echo
test "$status" -lt 400
92 changes: 29 additions & 63 deletions src/app/api/metrics/coding-activity-insights/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { githubRateLimitResponse } from "@/lib/github-rate-limit";
import { githubRequest } from "@/lib/github-request";
import { collectAuthorCommits, requireUnthrottled } from "@/lib/github-search";
import { getSessionWithToken } from "@/lib/get-session-token";
import { NextRequest } from "next/server";
import {
getAccountToken,
getAllAccounts,
} from "@/lib/github-accounts";
import { GITHUB_API } from "@/lib/github";
import { GitHubAuthError, githubAuthErrorResponse } from "@/lib/github-fetch";
import { getAccountToken, getAllAccounts } from "@/lib/github-accounts";
import { githubAuthErrorResponse } from "@/lib/github-fetch";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
Expand Down Expand Up @@ -68,58 +64,19 @@ async function fetchCommitTimestampsForAccount(
since.setDate(since.getDate() - SEARCH_WINDOW_DAYS);
const sinceStr = since.toISOString().slice(0, 10);

const timestamps: string[] = [];
let totalCount = 0;
let page = 1;

while (page <= 10) {
const response = await githubRequest(
`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`,
const { items } = requireUnthrottled(
await collectAuthorCommits<CommitSearchItem>(
githubLogin,
sinceStr,
token,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
}
);

if (!response.ok) {
if (response.status === 401) throw new GitHubAuthError();
if ((response.status === 403 || response.status === 429) && timestamps.length > 0) {
break;
fresh: cacheContext.bypass,
}

throw new Error("GitHub API error");
}

const payload = (await response.json()) as {
total_count: number;
items: CommitSearchItem[];
};

if (page === 1) {
totalCount = payload.total_count;
}

timestamps.push(
...payload.items
.map((item) => item.commit?.author?.date)
.filter((date): date is string => Boolean(date))
);

if (payload.items.length < 100) {
break;
}

if (timestamps.length >= 1000 || timestamps.length >= totalCount) {
break;
}

page += 1;
}

return timestamps;
)
);
return items
.map((item) => item.commit?.author?.date)
.filter((date): date is string => Boolean(date));
}
);
}
Expand All @@ -142,7 +99,11 @@ async function buildInsightsForAccount(
export async function GET(req: NextRequest) {
const sessionData = await getSessionWithToken();

if (!sessionData || !sessionData.session.githubLogin || !sessionData.session.githubId) {
if (
!sessionData ||
!sessionData.session.githubLogin ||
!sessionData.session.githubId
) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

Expand Down Expand Up @@ -244,10 +205,15 @@ export async function GET(req: NextRequest) {
return Response.json({ error: "Account not found" }, { status: 404 });
}

const data = await buildInsightsForAccount(token, accountRow.github_login, timeZone, {
bypass,
userId: accountId,
});
const data = await buildInsightsForAccount(
token,
accountRow.github_login,
timeZone,
{
bypass,
userId: accountId,
}
);

return Response.json(data);
} catch (e) {
Expand All @@ -256,4 +222,4 @@ export async function GET(req: NextRequest) {

return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
}
40 changes: 15 additions & 25 deletions src/app/api/metrics/commit-times/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { githubRateLimitResponse } from "@/lib/github-rate-limit";
import { githubRequest } from "@/lib/github-request";
import { collectAuthorCommits, requireUnthrottled } from "@/lib/github-search";
import { getServerSession } from "@/lib/server-session";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { GITHUB_API } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
Expand Down Expand Up @@ -39,28 +38,19 @@ export async function GET(req: NextRequest) {
new Array(24).fill(0)
);

let page = 1;
while (true) {
const res = await githubRequest(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`,
const { items } = requireUnthrottled(
await collectAuthorCommits(
session.githubLogin!,
sinceStr,
session.accessToken,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
fresh: bypass,
}
);
if (!res.ok) throw new Error("GitHub API error");
const data = (await res.json()) as {
items: Array<{ commit: { author: { date: string } } }>;
};
for (const item of data.items) {
const d = new Date(item.commit.author.date);
matrix[d.getDay()][d.getHours()]++;
}
if (data.items.length < 100 || page >= 10) break;
page++;
)
);
for (const item of items) {
const d = new Date(item.commit.author.date);
matrix[d.getDay()][d.getHours()]++;
}

// Find peak slot
Expand All @@ -83,8 +73,8 @@ export async function GET(req: NextRequest) {

return Response.json(result);
} catch (error) {
const githubLimit = githubRateLimitResponse(error);
if (githubLimit) return githubLimit;
const githubLimit = githubRateLimitResponse(error);
if (githubLimit) return githubLimit;
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
}
63 changes: 21 additions & 42 deletions src/app/api/metrics/consistency-score/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { githubRateLimitResponse } from "@/lib/github-rate-limit";
import { githubRequest } from "@/lib/github-request";
import { collectAuthorCommits, requireUnthrottled } from "@/lib/github-search";
import { getServerSession } from "@/lib/server-session";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { getAccountToken, getAllAccounts } from "@/lib/github-accounts";
import { GITHUB_API } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
Expand Down Expand Up @@ -44,46 +43,26 @@ async function fetchActiveDates(
const sinceStr = since.toISOString().slice(0, 10);

const activeDates = new Set<string>();
let page = 1;

while (true) {
const searchRes = await githubRequest(
`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
}
);

if (!searchRes.ok) {
throw new Error("GitHub API error");
}
const { items } = requireUnthrottled(
await collectAuthorCommits(githubLogin, sinceStr, token, {
fresh: cacheContext.bypass,
})
);

const data = (await searchRes.json()) as {
items: Array<{ commit: { author: { date: string } } }>;
};

for (const item of data.items) {
const commitDate = new Date(item.commit.author.date);
const parts = new Intl.DateTimeFormat("en", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(commitDate);
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (year && month && day) {
activeDates.add(`${year}-${month}-${day}`);
}
for (const item of items) {
const commitDate = new Date(item.commit.author.date);
const parts = new Intl.DateTimeFormat("en", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(commitDate);
const year = parts.find((p) => p.type === "year")?.value;
const month = parts.find((p) => p.type === "month")?.value;
const day = parts.find((p) => p.type === "day")?.value;
if (year && month && day) {
activeDates.add(`${year}-${month}-${day}`);
}

if (data.items.length < 100 || page >= 10) break;
page += 1;
}

return Array.from(activeDates);
Expand Down Expand Up @@ -240,8 +219,8 @@ export async function GET(req: NextRequest) {
});
return Response.json(result);
} catch (error) {
const githubLimit = githubRateLimitResponse(error);
if (githubLimit) return githubLimit;
const githubLimit = githubRateLimitResponse(error);
if (githubLimit) return githubLimit;
return Response.json({ error: "GitHub API error" }, { status: 502 });
}
}
38 changes: 13 additions & 25 deletions src/app/api/metrics/contributions/hourly/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { githubRateLimitResponse } from "@/lib/github-rate-limit";
import { githubRequest } from "@/lib/github-request";
import { collectAuthorCommits, requireUnthrottled } from "@/lib/github-search";
import { getServerSession } from "@/lib/server-session";
import { NextRequest } from "next/server";
import { authOptions } from "@/lib/auth";
import { GITHUB_API } from "@/lib/github";
import {
isMetricsCacheBypassed,
METRICS_CACHE_TTL_SECONDS,
Expand All @@ -21,7 +20,9 @@ export async function GET(req: NextRequest) {

const daysParam = req.nextUrl.searchParams.get("days");
const parsedDays = daysParam ? parseInt(daysParam, 10) : NaN;
const days = Number.isNaN(parsedDays) ? 30 : Math.max(1, Math.min(365, parsedDays));
const days = Number.isNaN(parsedDays)
? 30
: Math.max(1, Math.min(365, parsedDays));
const bypass = isMetricsCacheBypassed(req);
const key = metricsCacheKey(
session.githubId ?? session.githubLogin,
Expand All @@ -37,32 +38,20 @@ export async function GET(req: NextRequest) {
ttlSeconds: METRICS_CACHE_TTL_SECONDS.contributions,
},
async () => {
const since = new Date();
const since = new Date();
since.setDate(since.getDate() - days);
const sinceStr = since.toISOString().slice(0, 10);

const allItems: Array<{ commit: { author: { date: string } } }> = [];
let page = 1;

while (true) {
const searchRes = await githubRequest(
`${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`,
const { items: allItems } = requireUnthrottled(
await collectAuthorCommits(
session.githubLogin!,
sinceStr,
session.accessToken,
{
headers: {
Authorization: `Bearer ${session.accessToken}`,
Accept: "application/vnd.github+json",
},
cache: "no-store",
fresh: bypass,
}
);
if (!searchRes.ok) throw new Error("GitHub API error");
const data = (await searchRes.json()) as {
items: Array<{ commit: { author: { date: string } } }>;
};
allItems.push(...data.items);
if (data.items.length < 100 || page >= 10) break;
page++;
}
)
);

// Initialize all 24 hours to 0
const hourMap: Record<number, number> = {};
Expand All @@ -74,7 +63,6 @@ export async function GET(req: NextRequest) {
hourMap[hour]++;
}


const hours = Array.from({ length: 24 }, (_, i) => ({
hour: i,
commits: hourMap[i],
Expand Down
Loading
Loading