Skip to content

Commit

Projects: when each was last pushed, and how active it is lately

A workspace's projects page sorts hundreds of projects by recently pushed and most active, so the projects service keeps both from the events it already reads: pushed_at from every push, and an activity score that each push, issue or pull request opened or closed, review, comment and deployment raises by one and that halves every week (src/activity.ts). One row's update per event; migration 0009_activity.sql.

syntaqxcommitted Parenta85ec6bBrowse files
5 files+130−00/5 viewed
+8−0
5050 /** RFC 3339. */
5151 createdAt: string;
5252 updatedAt: string;
53+ /** RFC 3339: when its repository was last pushed to; null until it is, after projects began keeping it. */
54+ pushedAt: string | null;
55+ /**
56+ * How active it is lately: each push, issue or pull request opened or
57+ * closed, review, comment and deployment counts one, halving every week.
58+ * 0 for none.
59+ */
60+ activity: number;
5361 };
5462
5563 /** Whether a project deploys: decided from the project, or set by a person. */
+6−0
1+-- When a project's repository was last pushed to, and how active it is
2+-- lately, so a workspace's projects sort by either (src/activity.ts).
3+-- Null and 0 until something happens.
4+ALTER TABLE projects ADD COLUMN pushed_at TEXT;
5+ALTER TABLE projects ADD COLUMN active_at TEXT;
6+ALTER TABLE projects ADD COLUMN activity REAL NOT NULL DEFAULT 0;
+42−0
1+import { test } from "node:test";
2+import assert from "node:assert/strict";
3+
4+import { ACTIVE, decayed, raised } from "./activity.ts";
5+
6+const DAY = 24 * 60 * 60 * 1000;
7+const at = (days: number) => new Date(Date.UTC(2026, 9, 1) + days * DAY).toISOString();
8+
9+test("a first piece of activity scores one", () => {
10+ assert.deepEqual(raised(0, null, at(0)), { score: 1, at: at(0) });
11+});
12+
13+test("activity counts half as much a week later", () => {
14+ assert.equal(decayed(4, at(0), at(7)), 2);
15+ assert.equal(decayed(4, at(0), at(14)), 1);
16+ assert.equal(raised(4, at(0), at(7)).score, 3);
17+});
18+
19+test("a busy project outranks one busy long ago", () => {
20+ let busyThen = { score: 0, at: null as string | null };
21+ for (let i = 0; i < 20; i++) busyThen = raised(busyThen.score, busyThen.at, at(0));
22+ let busyNow = { score: 0, at: null as string | null };
23+ for (let i = 0; i < 5; i++) busyNow = raised(busyNow.score, busyNow.at, at(40));
24+ assert.ok(decayed(busyNow.score, busyNow.at, at(41)) > decayed(busyThen.score, busyThen.at, at(41)));
25+});
26+
27+test("an event delivered late never moves the time back", () => {
28+ const next = raised(2, at(5), at(3));
29+ assert.equal(next.at, at(5));
30+ assert.equal(next.score, 3);
31+});
32+
33+test("nonsense counts as nothing", () => {
34+ assert.equal(decayed(3, "not a time", at(0)), 0);
35+ assert.equal(decayed(-1, at(0), at(1)), 0);
36+});
37+
38+test("pushes, work and deployments count; housekeeping does not", () => {
39+ assert.ok(ACTIVE.has("git.push"));
40+ assert.ok(ACTIVE.has("pull.merged"));
41+ assert.ok(!ACTIVE.has("repo.created"));
42+});
+43−0
1+/**
2+ * How active a project is lately, for sorting a workspace's projects: each
3+ * push, issue or pull request opened or closed, review, comment and
4+ * deployment adds one, and what came before counts for half as much every
5+ * week. Kept as a score and the time it was last raised, so raising it is
6+ * one row's update. Pure, so it is tested apart from the service.
7+ */
8+
9+import type { EventType } from "@g1t/contracts";
10+
11+/** What happens to a project's code and work that counts as activity. */
12+export const ACTIVE: ReadonlySet<EventType> = new Set<EventType>([
13+ "git.push",
14+ "issue.opened",
15+ "issue.closed",
16+ "issue.reopened",
17+ "pull.opened",
18+ "pull.updated",
19+ "pull.merged",
20+ "pull.closed",
21+ "review.completed",
22+ "comment.created",
23+ "deployment.succeeded",
24+ "deployment.failed",
25+]);
26+
27+/** How long until activity counts for half. */
28+export const HALF_LIFE_MS = 7 * 24 * 60 * 60 * 1000;
29+
30+/** The score `score` raised at `lastAt` is worth at `at` (RFC 3339 times). */
31+export function decayed(score: number, lastAt: string | null, at: string): number {
32+ if (!lastAt || !(score > 0)) return 0;
33+ const elapsed = Date.parse(at) - Date.parse(lastAt);
34+ if (!Number.isFinite(elapsed)) return 0;
35+ // Events delivered out of order count in full: never worth more than they were.
36+ return score * Math.pow(0.5, Math.max(0, elapsed) / HALF_LIFE_MS);
37+}
38+
39+/** The score and its time after one more piece of activity at `at`. */
40+export function raised(score: number, lastAt: string | null, at: string): { score: number; at: string } {
41+ const later = !lastAt || Date.parse(at) >= Date.parse(lastAt) ? at : lastAt;
42+ return { score: decayed(score, lastAt, later) + 1, at: later };
43+}
+31−0
1717 import { NEEDS, repoRef } from "./access";
1818 import { effectiveDescription, ownDescription } from "./description";
1919 import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind";
20+import { ACTIVE, decayed, raised } from "./activity";
2021 import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins";
2122 import { renameStatements } from "./rename";
2223 import { moveStatements, slugOf, strandedQuery } from "./transfer";
9697 detected_commit?: string | null;
9798 linked_package?: string | null;
9899 deployments_on?: number | null;
100+ /** When its repository was last pushed to (migrations/0009_activity.sql). */
101+ pushed_at?: string | null;
102+ /** How active it is lately, as of `active_at` (src/activity.ts). */
103+ activity?: number | null;
104+ active_at?: string | null;
99105 };
100106
101107 /** How a package its repository publishes is named in why a project is a library. */
158164 createdBy: row.created_by,
159165 createdAt: row.created_at,
160166 updatedAt: row.updated_at,
167+ pushedAt: row.pushed_at ?? null,
168+ activity: decayed(row.activity ?? 0, row.active_at ?? null, now()),
161169 };
162170 }
163171
849857 }
850858 }
851859
860+ /** Something happened to a repository's code or work: its projects are that much more active. */
861+ private async active(repoId: string, at: string, pushed: boolean): Promise<void> {
862+ const rows = await this.db
863+ .prepare("SELECT id, activity, active_at FROM projects WHERE repo_id = ?")
864+ .bind(repoId)
865+ .all<{ id: string; activity: number | null; active_at: string | null }>();
866+ if (rows.results.length === 0) return;
867+ await this.db.batch(
868+ rows.results.map((row) => {
869+ const next = raised(row.activity ?? 0, row.active_at, at);
870+ return pushed
871+ ? this.db
872+ .prepare("UPDATE projects SET activity = ?, active_at = ?, pushed_at = MAX(COALESCE(pushed_at, ''), ?) WHERE id = ?")
873+ .bind(next.score, next.at, at, row.id)
874+ : this.db.prepare("UPDATE projects SET activity = ?, active_at = ? WHERE id = ?").bind(next.score, next.at, row.id);
875+ }),
876+ );
877+ }
878+
852879 async onEvent(event: G1tEvent): Promise<void> {
880+ if (ACTIVE.has(event.type)) {
881+ const repoId = event.repoId ?? ("repoId" in event.data ? (event.data as { repoId?: string }).repoId : undefined);
882+ if (repoId) await this.active(repoId, event.time, event.type === "git.push");
883+ }
853884 if (event.type === "workspace.renamed") {
854885 const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data);
855886 const statements = renameStatements(staleSlugs(event.data, current), current);