Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c995fa9fc | ||
|
|
d70cb18ea0 | ||
|
|
1d71787896 | ||
|
|
eb62289f89 | ||
|
|
f5969a2f9f | ||
|
|
7f3d259cf5 | ||
|
|
721149eda1 | ||
|
|
8e27bc1e36 | ||
|
|
f1212620e4 | ||
|
|
b1b18ae390 | ||
|
|
3f0a174392 | ||
|
|
c0edebefb9 |
+24
-2
@@ -9,6 +9,8 @@ import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
|
|||||||
/** A repository, trimmed to what the mesh cares about. */
|
/** A repository, trimmed to what the mesh cares about. */
|
||||||
export interface GiteaRepo {
|
export interface GiteaRepo {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
|
/** The URL a build clones — what a module records as its source. */
|
||||||
|
clone_url?: string;
|
||||||
name: string;
|
name: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
private: boolean;
|
private: boolean;
|
||||||
@@ -34,6 +36,9 @@ export interface GiteaPull {
|
|||||||
title: string;
|
title: string;
|
||||||
state: string;
|
state: string;
|
||||||
merged: boolean;
|
merged: boolean;
|
||||||
|
/** The commit the merge produced — what a build of the base branch is made from. */
|
||||||
|
merge_commit_sha?: string;
|
||||||
|
merged_at?: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
head?: string;
|
head?: string;
|
||||||
base?: string;
|
base?: string;
|
||||||
@@ -116,9 +121,23 @@ export class GiteaClient {
|
|||||||
|
|
||||||
// ---- Repositories ----
|
// ---- Repositories ----
|
||||||
|
|
||||||
|
/** Every repository this token can see, one page. `/user/repos` is only what the token's own
|
||||||
|
* user owns — for the mesh's administrator that is nothing, which is how the forge watched an
|
||||||
|
* empty list and announced no merge (2026-09-28). The search endpoint is the forge's whole view. */
|
||||||
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
async listRepos(page = 1, limit = 20): Promise<GiteaRepo[]> {
|
||||||
const repos = await this.request<any[]>(`/user/repos?page=${page}&limit=${limit}`);
|
const found = await this.request<{ data?: any[] }>(`/repos/search?page=${page}&limit=${limit}`);
|
||||||
return (repos ?? []).map(GiteaClient.mapRepo);
|
return (found?.data ?? []).map(GiteaClient.mapRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every repository, all pages. */
|
||||||
|
async listAllRepos(): Promise<GiteaRepo[]> {
|
||||||
|
const all: GiteaRepo[] = [];
|
||||||
|
for (let page = 1; page < 100; page++) {
|
||||||
|
const batch = await this.listRepos(page, 50);
|
||||||
|
all.push(...batch);
|
||||||
|
if (batch.length < 50) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRepo(data: {
|
async createRepo(data: {
|
||||||
@@ -232,6 +251,7 @@ export class GiteaClient {
|
|||||||
private static mapRepo(r: any): GiteaRepo {
|
private static mapRepo(r: any): GiteaRepo {
|
||||||
return {
|
return {
|
||||||
full_name: r.full_name,
|
full_name: r.full_name,
|
||||||
|
clone_url: r.clone_url ?? undefined,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
owner: r.owner?.login ?? r.full_name?.split("/")[0] ?? "unknown",
|
||||||
private: Boolean(r.private),
|
private: Boolean(r.private),
|
||||||
@@ -259,6 +279,8 @@ export class GiteaClient {
|
|||||||
title: p.title,
|
title: p.title,
|
||||||
state: p.state,
|
state: p.state,
|
||||||
merged: Boolean(p.merged),
|
merged: Boolean(p.merged),
|
||||||
|
merge_commit_sha: p.merge_commit_sha ?? undefined,
|
||||||
|
merged_at: p.merged_at ?? undefined,
|
||||||
user: p.user?.login,
|
user: p.user?.login,
|
||||||
head: p.head?.ref,
|
head: p.head?.ref,
|
||||||
base: p.base?.ref,
|
base: p.base?.ref,
|
||||||
|
|||||||
+59
-2
@@ -31,7 +31,7 @@ try {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let primed = false;
|
let primed = false;
|
||||||
async function pollRepos(client: GiteaClient): Promise<void> {
|
async function pollRepos(client: GiteaClient): Promise<void> {
|
||||||
const repos = await client.listRepos(1, 50);
|
const repos = await client.listAllRepos();
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
if (!seen.has(repo.full_name)) {
|
if (!seen.has(repo.full_name)) {
|
||||||
if (primed) {
|
if (primed) {
|
||||||
@@ -49,6 +49,62 @@ async function pollRepos(client: GiteaClient): Promise<void> {
|
|||||||
primed = true;
|
primed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A merge is announced whoever made it.** The merge tool below emits at the instant it acts; a
|
||||||
|
// merge made in the forge's own pages or over its API would emit nothing, and the mesh would go on
|
||||||
|
// believing every module current with its source (novox/hq 04-ISSUES/131). So merged pull requests
|
||||||
|
// are watched the way repositories are: what the forge holds, asked for on a tick, announced once.
|
||||||
|
// What has been announced is kept beside the module's state, so a restart does not announce the
|
||||||
|
// whole history again — and the first tick on a machine with no record announces nothing, because
|
||||||
|
// everything it sees then predates the watching.
|
||||||
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
const mergedRecord = process.env.MESH_GITEA_STATE_DIR ? join(process.env.MESH_GITEA_STATE_DIR, "merged-announced.json") : null;
|
||||||
|
const announced = new Set<string>();
|
||||||
|
let primedMerges = false;
|
||||||
|
if (mergedRecord && existsSync(mergedRecord)) {
|
||||||
|
try {
|
||||||
|
for (const sha of JSON.parse(readFileSync(mergedRecord, "utf8")) as string[]) announced.add(sha);
|
||||||
|
primedMerges = true;
|
||||||
|
} catch {
|
||||||
|
// An unreadable record is treated as no record: prime again rather than re-announce history.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function keepAnnounced(): void {
|
||||||
|
if (!mergedRecord) return;
|
||||||
|
mkdirSync(join(mergedRecord, ".."), { recursive: true });
|
||||||
|
const tmp = mergedRecord + ".tmp";
|
||||||
|
writeFileSync(tmp, JSON.stringify([...announced].slice(-2000)));
|
||||||
|
renameSync(tmp, mergedRecord);
|
||||||
|
}
|
||||||
|
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||||
|
const repos = await client.listAllRepos();
|
||||||
|
let changed = false;
|
||||||
|
for (const repo of repos) {
|
||||||
|
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||||
|
for (const pull of pulls) {
|
||||||
|
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) continue;
|
||||||
|
if (primedMerges) {
|
||||||
|
await emit("pull.merged", {
|
||||||
|
owner: repo.owner,
|
||||||
|
repo: repo.name,
|
||||||
|
number: pull.number,
|
||||||
|
title: pull.title,
|
||||||
|
head: pull.head,
|
||||||
|
base: pull.base,
|
||||||
|
merge_commit_sha: pull.merge_commit_sha,
|
||||||
|
merged_at: pull.merged_at,
|
||||||
|
clone_url: repo.clone_url,
|
||||||
|
html_url: pull.html_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
announced.add(pull.merge_commit_sha);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!primedMerges || changed) keepAnnounced();
|
||||||
|
primedMerges = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (gitea) {
|
if (gitea) {
|
||||||
const client = gitea;
|
const client = gitea;
|
||||||
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
// A poll that fails says so once, not once a minute: the same reason repeating (the forge not up
|
||||||
@@ -70,5 +126,6 @@ if (gitea) {
|
|||||||
run();
|
run();
|
||||||
};
|
};
|
||||||
tick(() => pollRepos(client), 60_000);
|
tick(() => pollRepos(client), 60_000);
|
||||||
console.log("[gitea] watching for new repositories");
|
tick(() => pollMerged(client), 30_000);
|
||||||
|
console.log("[gitea] watching for new repositories and merged pull requests");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||||
|
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
||||||
|
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||||
await emit("pull.merged", {
|
await emit("pull.merged", {
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
@@ -238,6 +240,8 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
|||||||
title: pull.title,
|
title: pull.title,
|
||||||
head: pull.head,
|
head: pull.head,
|
||||||
base: pull.base,
|
base: pull.base,
|
||||||
|
merge_commit_sha: merged.merge_commit_sha,
|
||||||
|
merged_at: merged.merged_at,
|
||||||
method,
|
method,
|
||||||
html_url: pull.html_url,
|
html_url: pull.html_url,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
#
|
#
|
||||||
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
|
# Unlike every other module's Dockerfile, this builds no TypeScript and uses no mesh base image:
|
||||||
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
|
# the module's code is the server, which upstream already built. There is no BUILD_BASE here on
|
||||||
# purpose — nothing is compiled.
|
# purpose — nothing is compiled. The upstream image is declared in the manifest under build.on and
|
||||||
FROM nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927
|
# arrives as NATS_BASE, like every other base the mesh copies into its own store before a build
|
||||||
|
# (novox/hq ADR 0097); the digest above is the index one for the reason given.
|
||||||
|
ARG NATS_BASE
|
||||||
|
FROM ${NATS_BASE}
|
||||||
|
|
||||||
COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint
|
COPY entrypoint.sh /usr/local/bin/mesh-nats-entrypoint
|
||||||
RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint
|
RUN chmod 0755 /usr/local/bin/mesh-nats-entrypoint
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
"id": "server-conf",
|
"id": "server-conf",
|
||||||
"type": "file",
|
"type": "file",
|
||||||
"path": "/var/lib/nats-module/conf/nats.conf",
|
"path": "/var/lib/nats-module/conf/nats.conf",
|
||||||
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n ca_file: \"/tls/ca.crt\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
"content": "# The nats module's own server settings. Declared by the module, because a port, a TLS path\n# and a store directory are properties of the container this module raises: they live in its\n# image and its mounts and change when it does.\n#\n# The mesh writes accounts.conf beside this one and nothing else. A controller that wrote the\n# whole file would have to be kept in step with a Dockerfile it never sees.\n\nport: 4222\nhttp: 127.0.0.1:8222\n\n# The mesh's own broker certificate \u2014 the one every machine already pins by fingerprint and the\n# controller already trusts (MESH_BROKER_CERTIFICATE). Serving the new bus with it means no\n# machine's pin changes when it moves, and no second certificate exists to be wrong about.\ntls {\n cert_file: \"/tls/tls.crt\"\n key_file: \"/tls/tls.key\"\n}\n\n# **No `verify`, deliberately, and it was `verify: true` until a probe ran this image.** That\n# setting makes the server demand a *client* certificate, and nothing in the mesh presents one: a\n# host pins this server's exact certificate and authenticates with the password the mesh minted\n# (novox/hq ADR 0004, design 25 \u00a74), and so does a module's runtime. With it on, every connection\n# in the mesh is refused at the TLS handshake, before any password is looked at \u2014 and the error is\n# \"client didn't provide a certificate\", which reads as a client fault.\n#\n# TLS is still required: a tls block is what makes it required, and verify only decides whether\n# client certificates are checked. What is given up is a second factor the mesh has no machinery\n# to issue or rotate \u2014 a certificate per module per node \u2014 and what is kept is stronger than a\n# name check in both directions: an exact pin outward, a per-user password inward.\n\njetstream {\n store_dir: \"/data\"\n}\n\n# Every user of the mesh, composed by the controller and rewritten whenever a module is\n# assigned, a node enrols or a person's access changes.\n#\n# **Relative, and in this same directory, because it has to be.** An absolute include path is\n# resolved relative to the including file's directory, not from the root: nats-server given\n# `include /etc/nats/accounts.conf` from /etc/nats-server/nats.conf looks for\n# /etc/nats-server/etc/nats/accounts.conf and refuses to start. Verified against the server.\ninclude accounts.conf\n",
|
||||||
"mode": "0644"
|
"mode": "0644"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,18 +61,24 @@
|
|||||||
"volumes": [
|
"volumes": [
|
||||||
"/var/lib/mesh-broker-nats:/data",
|
"/var/lib/mesh-broker-nats:/data",
|
||||||
"/var/lib/nats-module/conf:/etc/nats:ro",
|
"/var/lib/nats-module/conf:/etc/nats:ro",
|
||||||
"/var/lib/mesh-broker-nats-tls:/tls:ro"
|
"/var/lib/mesh-broker-tls:/tls:ro"
|
||||||
],
|
],
|
||||||
"artifact": "server"
|
"artifact": "server"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"accesses": [
|
"accesses": [
|
||||||
{
|
{
|
||||||
"path": "/var/lib/mesh-broker-nats-tls",
|
"path": "/var/lib/mesh-broker-tls",
|
||||||
"mode": "read"
|
"mode": "read"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"build": {
|
"build": {
|
||||||
|
"on": [
|
||||||
|
{
|
||||||
|
"arg": "NATS_BASE",
|
||||||
|
"image": "nats@sha256:b83efabe3e7def1e0a4a31ec6e078999bb17c80363f881df35edc70fcb6bb927"
|
||||||
|
}
|
||||||
|
],
|
||||||
"artifacts": [
|
"artifacts": [
|
||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
|
|||||||
Reference in New Issue
Block a user