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.
5 files+130−00/5 viewed
| 50 | 50 | /** RFC 3339. */ | |
| 51 | 51 | createdAt: string; | |
| 52 | 52 | 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; | |
| 53 | 61 | }; | |
| 54 | 62 | ||
| 55 | 63 | /** Whether a project deploys: decided from the project, or set by a person. */ |
| 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; |
| 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 | + | }); |
| 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 | + | } |
| 17 | 17 | import { NEEDS, repoRef } from "./access"; | |
| 18 | 18 | import { effectiveDescription, ownDescription } from "./description"; | |
| 19 | 19 | import { type KindFacts, type RootFiles, MANIFESTS, deploysSetting, detectKind, goFilesToRead, resolveKind } from "./kind"; | |
| 20 | + | import { ACTIVE, decayed, raised } from "./activity"; | |
| 20 | 21 | import { MAX_PINS, MAX_RECENT, MAX_VISITS, placePin, recentAfterPins, reorderPins } from "./pins"; | |
| 21 | 22 | import { renameStatements } from "./rename"; | |
| 22 | 23 | import { moveStatements, slugOf, strandedQuery } from "./transfer"; | |
| 96 | 97 | detected_commit?: string | null; | |
| 97 | 98 | linked_package?: string | null; | |
| 98 | 99 | 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; | |
| 99 | 105 | }; | |
| 100 | 106 | ||
| 101 | 107 | /** How a package its repository publishes is named in why a project is a library. */ | |
| 158 | 164 | createdBy: row.created_by, | |
| 159 | 165 | createdAt: row.created_at, | |
| 160 | 166 | updatedAt: row.updated_at, | |
| 167 | + | pushedAt: row.pushed_at ?? null, | |
| 168 | + | activity: decayed(row.activity ?? 0, row.active_at ?? null, now()), | |
| 161 | 169 | }; | |
| 162 | 170 | } | |
| 163 | 171 | ||
| 849 | 857 | } | |
| 850 | 858 | } | |
| 851 | 859 | ||
| 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 | + | ||
| 852 | 879 | 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 | + | } | |
| 853 | 884 | if (event.type === "workspace.renamed") { | |
| 854 | 885 | const current = await currentWorkspaceSlug(this.env.IDENTITY, event.data); | |
| 855 | 886 | const statements = renameStatements(staleSlugs(event.data, current), current); |