feat: show call participants in room list (Discord-style)
Docker / Docker Buildx (push) Has been cancelled
Build Debian package / Build package (release) Has been cancelled
Build and Deploy / prepare (release) Has been cancelled
Deploy release / Deploy to Cloudflare Pages (release) Has been cancelled
Build and Deploy / Trigger Pro pipeline (release) Has been cancelled
Build and Deploy / Windows arm64 (release) Has been cancelled
Build and Deploy / Windows x64 (release) Has been cancelled
Build and Deploy / macOS (release) Has been cancelled
Build and Deploy / Linux amd64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / Linux arm64 (sqlcipher static) (release) Has been cancelled
Build and Deploy / ${{ needs.prepare.outputs.deploy == 'true' && 'Deploy' || 'Deploy (dry-run)' }} (release) Has been cancelled
Build and Deploy / Deploy builds to ESS (release) Has been cancelled

This commit is contained in:
sorB
2026-05-10 14:25:35 +02:00
parent b797925316
commit 3da363517f
4610 changed files with 827237 additions and 1 deletions
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
import * as fs from "node:fs";
import { exec } from "node:child_process";
const includeJSSDK = process.argv.includes("--include-js-sdk");
const ignore: string[] = [];
ignore.push(...Object.values<string>(JSON.parse(fs.readFileSync(`${__dirname}/../components.json`, "utf-8"))));
ignore.push("/index.ts");
ignore.push("/jest-matrix-react.tsx");
ignore.push("/customisations/");
ignore.push("/test-utils/");
// The following ignores are temporary and false-positives which need to be fixed
ignore.push("/useLocalStorageState.ts");
ignore.push("/blurhash.worker.ts");
ignore.push("/OpenSpotlightPayload.ts");
ignore.push("/PinnedMessageBadge.tsx");
ignore.push("/editor/mock.ts");
ignore.push("DeviceIsolationModeController.ts");
ignore.push("urls.ts");
ignore.push("/json.ts");
ignore.push("/ReleaseAnnouncementStore.ts");
ignore.push("/WidgetLayoutStore.ts");
ignore.push("/common.ts");
// We ignore js-sdk by default as it may export for other non element-web projects
if (!includeJSSDK) ignore.push("matrix-js-sdk");
const command = `pnpm ts-prune --ignore "${ignore.join("|")}" | grep -v "(used in module)"`;
exec(command, (error, stdout, stderr) => {
if (error) throw error;
// We have to do this as piping the output of ts-prune causes the return
// code to be 0
if (stderr) throw Error(stderr);
let lines = stdout.split("\n");
// Remove the first line as that is the command that was being run and we
// log that only in case of an error
lines.splice(0, 1);
// Remove the last line as it is empty
lines.pop();
// ts-prune has bug where if the unused export is in a dependency, the path
// won't have an "/" character at the start, so we try to fix that for
// better UX
// TODO: This might break on Windows
lines = lines.reduce<string[]>((newLines, line) => {
if (!line.startsWith("/")) newLines.push("/" + line);
else newLines.push(line);
return newLines;
}, []);
// If an unused export has been found, we error
if (lines.length > 0) {
console.log(`Command that was run: ${command}`);
console.log(lines.join("\n"));
throw Error("Unused exports found!");
}
console.log("Success - no unused exports found!");
});
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -ex
# Automatically link to develop if we're building develop, but only if the caller
# hasn't asked us to build something else
BRANCH=$(git rev-parse --abbrev-ref HEAD)
if [[ $USE_CUSTOM_SDKS == false ]] && [[ $BRANCH == 'develop' ]]
then
echo "using develop dependencies for react-sdk and js-sdk"
USE_CUSTOM_SDKS=true
JS_SDK_BRANCH='develop'
fi
if [[ $USE_CUSTOM_SDKS == false ]]
then
echo "skipping js-sdk install: USE_CUSTOM_SDKS is false"
exit 0
fi
echo "Linking js-sdk"
git clone --depth 1 --branch $JS_SDK_BRANCH "$JS_SDK_REPO" js-sdk
cd js-sdk
pnpm install
cd ../
echo "Setting up element-web with js-sdk package"
pnpm link ./js-sdk
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -ex
BRANCH=$(git rev-parse --abbrev-ref HEAD)
DIR=$(dirname "$0")
# If the branch comes out as HEAD then we're probably checked out to a tag, so if the thing is *not*
# coming out as HEAD then we're on a branch. When we're on a branch, we want to resolve ourselves to
# a few SHAs rather than a version.
if [[ $BRANCH != HEAD && ! $BRANCH =~ heads/v.+ ]]
then
DIST_VERSION=$("$DIR"/get-version-from-git.sh)
else
DIST_VERSION=$(git describe --abbrev=0 --tags)
fi
DIST_VERSION=$("$DIR"/normalize-version.sh "$DIST_VERSION")
VERSION=$DIST_VERSION pnpm --dir apps/web build
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
set -x
deforg="$1"
defrepo="$2"
defbranch="$3"
rm -r "$defrepo" || true
# figure out where to look for pull requests:
# - We may have been told an explicit repo via the PR_ORG/PR_REPO/PR_NUMBER env vars
# - otherwise, check the $GITHUB_ env vars which are set by Github Actions
# - failing that, fall back to the element-hq/element-web repo.
#
# in ether case, the PR_NUMBER variable must be set explicitly.
default_org_repo=${GITHUB_REPOSITORY:-"element-hq/element-web"}
PR_ORG=${PR_ORG:-${default_org_repo%%/*}}
PR_REPO=${PR_REPO:-${default_org_repo##*/}}
# A function that clones a branch of a repo based on the org, repo and branch
clone() {
org=$1
repo=$2
branch=$3
if [ -n "$branch" ]
then
echo "Trying to use $org/$repo#$branch"
# Disable auth prompts: https://serverfault.com/a/665959
if GIT_TERMINAL_PROMPT=0 git clone "https://github.com/$org/$repo.git" "$repo" --branch "$branch" --depth 1; then
git -C "$repo" log -1
exit 0
fi
fi
}
# A function that gets info about a PR from the GitHub API based on its number
getPRInfo() {
number=$1
if [ -n "$number" ]; then
echo "Getting info about a PR with number $number"
apiEndpoint="https://api.github.com/repos/$PR_ORG/$PR_REPO/pulls/$number"
head=$(curl "$apiEndpoint" | jq -r '.head.label')
fi
}
# Some CIs don't give us enough info, so we just get the PR number and ask the
# GH API for more info - "fork:branch". Some give us this directly.
if [ -n "$PR_NUMBER" ]; then
# GitHub
getPRInfo "$PR_NUMBER"
elif [ -n "$REVIEW_ID" ]; then
# Netlify
getPRInfo "$REVIEW_ID"
fi
# for forks, $head will be in the format "fork:branch", so we split it by ":"
# into an array. On non-forks, this has the effect of splitting into a single
# element array given ":" shouldn't appear in the head - it'll just be the
# branch name. Based on the results, we clone.
# shellcheck disable=SC2206
BRANCH_ARRAY=(${head//:/ })
TRY_ORG=$deforg
TRY_BRANCH=${BRANCH_ARRAY[0]}
if [[ "$head" == *":"* ]]; then
# ... but only match that fork if it's a real fork
if [ "${BRANCH_ARRAY[0]}" != "$PR_ORG" ]; then
TRY_ORG=${BRANCH_ARRAY[0]}
fi
TRY_BRANCH=${BRANCH_ARRAY[1]}
fi
clone "$TRY_ORG" "$defrepo" "$TRY_BRANCH"
# For merge queue runs we need to extract the temporary branch name
# the ref_name will look like `gh-readonly-queue/<branch>/pr-<number>-<sha>`
if [[ "$GITHUB_EVENT_NAME" == "merge_group" ]]; then
withoutPrefix=${GITHUB_REF_NAME#gh-readonly-queue/}
clone "$deforg" "$defrepo" "${withoutPrefix%%/pr-*}"
fi
# Try the target branch of the push or PR, or the branch that was pushed to
# (ie. the 'master' branch should use matching 'master' dependencies)
base_or_branch=$GITHUB_BASE_REF
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
base_or_branch=${GITHUB_REF}
fi
if [ -n "$base_or_branch" ]; then
clone "$deforg" "$defrepo" "$base_or_branch"
fi
# Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds)
clone "$deforg" "$defrepo" "$HEAD"
# Use the default branch as the last resort.
clone "$deforg" "$defrepo" "$defbranch"
+667
View File
@@ -0,0 +1,667 @@
import fs from "node:fs/promises";
import path from "node:path";
import YAML from "yaml";
import parseArgs from "minimist";
import cronstrue from "cronstrue";
import _ from "lodash";
import url from "node:url";
/**
* Generates unique ID strings (incremental base36) representing the given inputs.
*/
class IdGenerator<T> {
private id = 0;
private map = new Map<T, string>();
public get(s: T): string {
if (this.map.has(s)) return this.map.get(s)!;
const id = "ID" + this.id.toString(36).toLowerCase();
this.map.set(s, id);
this.id++;
return id;
}
public debug(): void {
console.log("```");
console.log(this.map);
console.log("```");
}
}
/**
* Type representing a node on a graph with additional metadata
*/
interface Node {
// Workflows are keyed by project/name??id
// Jobs are keyed by id
// Triggers are keyed by id
id: string;
name: string;
shape:
| "round edges"
| "stadium"
| "subroutine"
| "cylinder"
| "circle"
| "flag"
| "rhombus"
| "hexagon"
| "parallelogram"
| "parallelogram_alt"
| "trapezoid"
| "trapezoid_alt"
| "double_circle";
link?: string;
}
/**
* Type representing a directed edge on a graph with an optional label
*/
type Edge<T> = [source: T, destination: T, label?: string];
class Graph<T extends Node> {
public nodes = new Map<string, T>();
public edges: Edge<T>[] = [];
public addNode(node: T): void {
if (!this.nodes.has(node.id)) {
this.nodes.set(node.id, node);
}
}
public removeNode(node: T): Edge<T>[] {
if (!this.nodes.has(node.id)) return [];
this.nodes.delete(node.id);
const [removedEdges, keptEdges] = _.partition(
this.edges,
([source, destination]) => source === node || destination === node,
);
this.edges = keptEdges;
return removedEdges;
}
public addEdge(source: T, destination: T, label?: string): void {
if (this.edges.some(([_source, _destination]) => _source === source && _destination === destination)) return;
this.edges.push([source, destination, label]);
}
// Removes nodes without any edges
public cull(): void {
const seenNodes = new Set<Node>();
this.edges.forEach(([source, destination]) => {
seenNodes.add(source);
seenNodes.add(destination);
});
this.nodes.forEach((node) => {
if (!seenNodes.has(node)) {
this.nodes.delete(node.id);
}
});
}
public get roots(): Set<T> {
const roots = new Set(this.nodes.values());
this.edges.forEach(([source, destination]) => {
roots.delete(destination);
});
return roots;
}
private componentsRecurse(root: T, visited: Set<T>): T[] {
if (visited.has(root)) return [root];
visited.add(root);
const neighbours = [root];
this.edges.forEach(([source, destination]) => {
if (source === root) {
neighbours.push(...this.componentsRecurse(destination, visited));
} else if (destination === root) {
neighbours.push(...this.componentsRecurse(source, visited));
}
});
return neighbours;
}
public get components(): Graph<T>[] {
const graphs: Graph<T>[] = [];
const visited = new Set<T>();
this.nodes.forEach((node) => {
if (visited.has(node)) return;
const graph = new Graph<T>();
graphs.push(graph);
const nodes = this.componentsRecurse(node, visited);
nodes.forEach((node) => {
graph.addNode(node);
this.edges.forEach((edge) => {
if (edge[0] === node || edge[1] === node) {
graph.addEdge(...edge);
}
});
});
});
return graphs;
}
}
/**
* Type representing a GitHub project
*/
interface Project {
url: string;
name: string;
path: string;
workflows: Map<string, Workflow>;
}
/**
* Type representing a GitHub Actions Workflow
*/
interface Workflow extends Node {
path: string;
project: Project;
jobs: Job[];
on: WorkflowYaml["on"];
}
/**
* Type representing a job within a GitHub Actions Workflow
*/
interface Job extends Node {
jobId: string; // id relative to workflow
needs?: string[];
strategy?: {
matrix: {
[key: string]: string[];
} & {
include?: Record<string, string>[];
exclude?: Record<string, string>[];
};
};
}
/**
* Type representing the YAML structure of a GitHub Actions Workflow file
*/
interface WorkflowYaml {
name: string;
on: {
workflow_run?: {
workflows: string[];
}; // Magic
workflow_call?: {}; // Reusable
workflow_dispatch?: {}; // Manual
pull_request?: {};
merge_group?: {};
push?: {
tags?: string[];
branches?: string[];
};
schedule?: { cron: string }[];
release?: {};
//
label?: {};
issues?: {};
};
jobs: {
[job: string]: {
name?: string;
needs?: string | string[];
strategy?: Job["strategy"];
};
};
}
/**
* Type representing a trigger of a GitHub Actions Workflow
*/
type Trigger = Node;
// TODO workflow_call reusables
/* eslint-disable @typescript-eslint/naming-convention */
const TRIGGERS: {
[key in keyof WorkflowYaml["on"]]: (
data: NonNullable<WorkflowYaml["on"][key]>,
workflow: Workflow,
) => Trigger | Trigger[];
} = {
workflow_dispatch: () => ({
id: "on:workflow_dispatch",
name: "Manual",
shape: "circle",
}),
issues: (_, { project }) => ({ id: `on:issues/${project.name}`, name: `${project.name} Issues`, shape: "circle" }),
label: (_, { project }) => ({ id: "on:label", name: "on: Label", shape: "circle" }),
release: (_, { project }) => ({
id: `on:release/${project.name}`,
name: `${project.name} Release`,
shape: "circle",
}),
push: (data, { project }) => {
const nodes: Trigger[] = [];
data.tags?.forEach((tag) => {
const name = `Push ${project.name}<br>tag ${tag}`;
nodes.push({ id: `on:push/${project.name}/tag/${tag}`, name, shape: "circle" });
});
data.branches?.forEach((branch) => {
const name = `Push ${project.name}<br>${branch}`;
nodes.push({ id: `on:push/${project.name}/branch/${branch}`, name, shape: "circle" });
});
return nodes;
},
schedule: (data) =>
data.map(({ cron }) => ({
id: `on:schedule/${cron}`,
name: cronstrue.toString(cron).replaceAll(", ", "<br>"),
shape: "circle",
})),
pull_request: (_, { project }) => ({
id: `on:pull_request/${project.name}`,
name: `Pull Request<br>${project.name}`,
shape: "circle",
}),
// TODO should we be just dropping these?
workflow_run: (data) => data.workflows.map((parent) => workflows.get(parent)).filter(Boolean) as Workflow[],
};
/* eslint-enable @typescript-eslint/naming-convention */
const triggers = new Map<string, Trigger>(); // keyed by trigger id
const projects = new Map<string, Project>(); // keyed by project name
const workflows = new Map<string, Workflow>(); // keyed by workflow name
function getTriggerNodes<K extends keyof WorkflowYaml["on"]>(key: K, workflow: Workflow, on?: string[]): Trigger[] {
if (!TRIGGERS[key]) return [];
if (on && !on.includes(key)) {
return [];
}
const data = workflow.on[key]!;
const nodes = toArray(TRIGGERS[key]!(data, workflow));
return nodes.map((node) => {
if (triggers.has(node.id)) return triggers.get(node.id)!;
triggers.set(node.id, node);
return node;
});
}
function readFile(...pathSegments: string[]): Promise<string> {
return fs.readFile(path.join(...pathSegments), { encoding: "utf-8" });
}
async function readJson<T extends object>(...pathSegments: string[]): Promise<T> {
return JSON.parse(await readFile(...pathSegments));
}
async function readYaml<T extends object>(...pathSegments: string[]): Promise<T> {
return YAML.parse(await readFile(...pathSegments));
}
function toArray<T>(v: T | T[]): T[] {
return Array.isArray(v) ? v : [v];
}
function cartesianProduct<T>(sets: T[][]): T[][] {
return sets.reduce<T[][]>(
(results, ids) =>
results
.map((result) => ids.map((id) => [...result, id]))
.reduce((nested, result) => [...nested, ...result]),
[[]],
);
}
function shallowCompare(obj1: Record<string, any>, obj2: Record<string, any>): boolean {
return (
Object.keys(obj1).length === Object.keys(obj2).length &&
Object.keys(obj1).every((key) => obj1[key] === obj2[key])
);
}
class MermaidFlowchartPrinter {
private static INDENT = 4;
private currentIndent = 0;
private text = "";
private readonly markdown: boolean;
public readonly idGenerator = new IdGenerator();
private print(text: string): void {
this.text += " ".repeat(this.currentIndent) + text + "\n";
}
public finish(print: boolean): string {
this.indent(-1);
if (this.markdown) this.print("```\n");
if (print) console.log(this.text);
return this.text;
}
private indent(delta = 1): void {
this.currentIndent += delta * MermaidFlowchartPrinter.INDENT;
}
public constructor(direction: "TD" | "TB" | "BT" | "RL" | "LR", title?: string, markdown = false) {
this.markdown = markdown;
if (this.markdown) {
this.print("```mermaid");
}
// Print heading
if (title) {
this.print("---");
this.print(`title: ${title}`);
this.print("---");
}
this.print(`flowchart ${direction}`);
this.indent();
}
public subgraph(id: string, name: string, fn: () => void): void {
this.print(`subgraph ${this.idGenerator.get(id)}["${name}"]`);
this.indent();
fn();
this.indent(-1);
this.print("end");
}
public node(node: Node): void {
const id = this.idGenerator.get(node.id);
const name = node.name.replaceAll('"', "'");
switch (node.shape) {
case "round edges":
this.print(`${id}("${name}")`);
break;
case "stadium":
this.print(`${id}(["${name}"])`);
break;
case "subroutine":
this.print(`${id}[["${name}"]]`);
break;
case "cylinder":
this.print(`${id}[("${name}")]`);
break;
case "circle":
this.print(`${id}(("${name}"))`);
break;
case "flag":
this.print(`${id}>"${name}"]`);
break;
case "rhombus":
this.print(`${id}{"${name}"}`);
break;
case "hexagon":
this.print(`${id}{{"${name}"}}`);
break;
case "parallelogram":
this.print(`${id}[/"${name}"/]`);
break;
case "parallelogram_alt":
this.print(`${id}[\\"${name}"\\]`);
break;
case "trapezoid":
this.print(`${id}[/"${name}"\\]`);
break;
case "trapezoid_alt":
this.print(`${id}[\\"${name}"/]`);
break;
case "double_circle":
this.print(`${id}((("${name}")))`);
break;
}
if (node.link) {
this.print(`click ${id} href "${node.link}" "Click to open workflow"`);
}
}
public edge(source: Node, destination: Node, text?: string): void {
const sourceId = this.idGenerator.get(source.id);
const destinationId = this.idGenerator.get(destination.id);
if (text) {
this.print(`${sourceId}-- ${text} -->${destinationId}`);
} else {
this.print(`${sourceId} --> ${destinationId}`);
}
}
}
function debugGraph(name: string, graph: Graph<any>): void {
console.log("```");
console.log(`## ${name}`);
console.log(new Map(graph.nodes));
console.log(graph.edges.map((edge) => ({ source: edge[0].id, destination: edge[1].id, text: edge[2] })));
console.log("```");
console.log("");
}
export default async function main(dirs: string[], on?: string[], print = false, debug = false): Promise<string> {
// Data ingest
for (const projectPath of dirs) {
const {
name,
repository: { url },
} = await readJson<{ name: string; repository: { url: string } }>(projectPath, "package.json");
const workflowsPath = path.join(projectPath, ".github", "workflows");
let files: string[];
try {
files = await fs.readdir(workflowsPath);
} catch (e) {
console.error(`Could not read ${workflowsPath}`, e);
continue;
}
const project: Project = {
name,
url,
path: projectPath,
workflows: new Map(),
};
for (const file of files.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"))) {
const data = await readYaml<WorkflowYaml>(workflowsPath, file);
const name = data.name ?? file;
const workflow: Workflow = {
id: `${project.name}/${name}`,
name,
shape: "hexagon",
path: path.join(workflowsPath, file),
project,
link: `${project.url}/blob/develop/.github/workflows/${file}`,
on: data.on,
jobs: [],
};
for (const jobId in data.jobs) {
const job = data.jobs[jobId];
workflow.jobs.push({
id: `${workflow.name}/${jobId}`,
jobId,
name: job.name ?? jobId,
strategy: job.strategy,
needs: job.needs ? toArray(job.needs) : undefined,
shape: "subroutine",
link: `${project.url}/blob/develop/.github/workflows/${file}`,
});
}
project.workflows.set(name, workflow);
workflows.set(name, workflow);
}
projects.set(name, project);
}
const graph = new Graph<Workflow | Node>();
for (const workflow of workflows.values()) {
if (on && !on.some((trigger) => trigger in workflow.on)) {
continue;
}
graph.addNode(workflow);
Object.keys(workflow.on).forEach((trigger) => {
const nodes = getTriggerNodes(trigger as keyof WorkflowYaml["on"], workflow, on);
nodes.forEach((node) => {
graph.addNode(node);
graph.addEdge(node, workflow, "project" in node ? "workflow_run" : undefined);
});
});
}
// TODO separate disconnected nodes into their own graph
graph.cull();
// This is an awful hack to make the output graphs much better by allowing the splitting of certain nodes //
const bifurcatedNodes = [triggers.get("on:workflow_dispatch")].filter(Boolean) as Node[];
const removedEdgeMap = new Map<Node, Edge<any>[]>();
for (const node of bifurcatedNodes) {
removedEdgeMap.set(node, graph.removeNode(node));
}
const components = graph.components;
for (const node of bifurcatedNodes) {
const removedEdges = removedEdgeMap.get(node)!;
components.forEach((graph) => {
removedEdges.forEach((edge) => {
if (graph.nodes.has(edge[1].id)) {
graph.addNode(node);
graph.addEdge(...edge);
}
});
});
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (debug) {
debugGraph("global", graph);
}
let text = "";
components.forEach((graph) => {
const title = [...graph.roots]
.map((root) => root.name)
.join(" & ")
.replaceAll("<br>", " ");
const printer = new MermaidFlowchartPrinter("LR", title, true);
graph.nodes.forEach((node) => {
if ("project" in node) {
// TODO unsure about this edge
// if (node.jobs.length === 1) {
// printer.node(node);
// return;
// }
// TODO handle job.if on github.event_name
const subgraph = new Graph<Job>();
for (const job of node.jobs) {
subgraph.addNode(job);
if (job.needs) {
toArray(job.needs).forEach((req) => {
subgraph.addEdge(node.jobs.find((job) => job.jobId === req)!, job, "needs");
});
}
}
printer.subgraph(node.id, node.name, () => {
subgraph.edges.forEach(([source, destination, text]) => {
printer.edge(source, destination, text);
});
subgraph.nodes.forEach((job) => {
if (!job.strategy?.matrix) {
printer.node(job);
return;
}
let variations = cartesianProduct(
Object.keys(job.strategy.matrix)
.filter(
(key) =>
key !== "include" &&
key !== "exclude" &&
Array.isArray(job.strategy!.matrix[key]),
)
.map((matrixKey) => {
return job.strategy!.matrix[matrixKey].map((value) => ({ [matrixKey]: value }));
}),
)
.map((variation) => Object.assign({}, ...variation))
.filter((variation) => Object.keys(variation).length > 0);
if (job.strategy.matrix.include) {
variations.push(...job.strategy.matrix.include);
}
job.strategy.matrix.exclude?.forEach((exclusion) => {
variations = variations.filter((variation) => {
return !shallowCompare(exclusion, variation);
});
});
// TODO validate edge case
if (variations.length === 0) {
printer.node(job);
return;
}
const jobName = job.name.replace(/\${{.+}}/g, "").replace(/(?:\(\)| )+/g, " ");
printer.subgraph(job.id, jobName, () => {
variations.forEach((variation, i) => {
let variationName = job.name;
if (variationName.includes("${{ matrix.")) {
Object.keys(variation).map((key) => {
variationName = variationName.replace(`\${{ matrix.${key} }}`, variation[key]);
});
} else {
variationName = `${variationName} (${Object.values(variation).join(", ")})`;
}
printer.node({ ...job, id: `${job.id}-variation-${i}`, name: variationName });
});
});
});
});
return;
}
printer.node(node);
});
graph.edges.forEach(([sourceName, destinationName, text]) => {
printer.edge(sourceName, destinationName, text);
});
text += printer.finish(print);
if (debug) {
printer.idGenerator.debug();
debugGraph("subgraph", graph);
}
});
return text;
}
if (import.meta.url.startsWith("file:")) {
const modulePath = url.fileURLToPath(import.meta.url);
if (process.argv[1] === modulePath) {
const argv = parseArgs<{
debug: boolean;
on: string | string[];
}>(process.argv.slice(2), {
string: ["on"],
boolean: ["debug"],
});
const on = typeof argv.on === "string" || Array.isArray(argv.on) ? toArray(argv.on) : undefined;
main(argv._, on, true, argv.debug)
.then((ret) => {
process.exit(0);
})
.catch((e) => {
console.error(e);
process.exit(1);
});
}
}
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Echoes a version based on the git hashes of the element-web & js-sdk checkouts, for the case where
# these dependencies are git checkouts.
set -e
# Since the deps are fetched from git & linked, we can rev-parse
JSSDK_SHA=$(git -C $(pnpm -w root)/matrix-js-sdk rev-parse --short=12 HEAD)
VECTOR_SHA=$(git rev-parse --short=12 HEAD) # use the ACTUAL SHA rather than assume develop
echo "$VECTOR_SHA-js-$JSSDK_SHA"
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env perl
use warnings;
use strict;
use Net::GitHub;
use Time::Moment;
use Term::ReadPassword;
# This version of the script emits the cumulative number of bugs, split into open & closed
# suitable for drawing the 'top' and 'bottom' of a burndown graph.
#
# N.B. this doesn't take into account issues changing priority over time, but only their most recent priority.
#
# If you want instead the number of open issues on a given day, then look at issues-no-state.pl
my $gh = Net::GitHub->new(
login => 'ara4n', pass => read_password("github password: "),
);
$gh->set_default_user_repo('element-hq', 'element-web');
#my @issues = $gh->issue->repos_issues({ state => 'all', milestone => 3 });
my @issues = $gh->issue->repos_issues({ state => 'all' });
while ($gh->issue->has_next_page) {
push @issues, $gh->issue->next_page;
}
# we want:
# day by day:
# split by { open, closed }
# split by { bug, feature, neither }
# each split by { p1, p2, p3, p4, p5, unprioritised } <- priority
# each split by { minor, major, critical, cosmetic, network, no-severity } <- severity
# then split (with overlap between the groups) as { total, tag1, tag2, ... }?
# ...and then all over again split by milestone.
my $days = {};
my $schema = {};
my $now = Time::Moment->now;
foreach my $issue (@issues) {
next if ($issue->{pull_request});
# use Data::Dumper;
# print STDERR Dumper($issue);
my @label_list = map { $_->{name} } @{$issue->{labels}};
my $labels = {};
$labels->{$_} = 1 foreach (@label_list);
$labels->{bug}++ if ($labels->{cosmetic} && !$labels->{bug} && !$labels->{feature});
my $extract_labels = sub {
my $label = undef;
foreach (@_) {
$label ||= $_ if (delete $labels->{$_});
}
return $label;
};
my $state = $issue->{state};
my $type = &$extract_labels(qw(bug feature)) || "neither";
my $priority = &$extract_labels(qw(p1 p2 p3 p4 p5)) || "unprioritised";
my $severity = &$extract_labels(qw(minor major critical cosmetic network)) || "no-severity";
my $start = Time::Moment->from_string($issue->{created_at});
do {
my $ymd = $start->strftime('%F');
$days->{ $ymd }->{ 'created' }->{ $type }->{ $priority }->{ $severity }->{ total }++;
$schema->{ 'created' }->{ $type }->{ $priority }->{ $severity }->{ total }++;
foreach (keys %$labels) {
$days->{ $ymd }->{ 'created' }->{ $type }->{ $priority }->{ $severity }->{ $_ }++;
$schema->{ 'created' }->{ $type }->{ $priority }->{ $severity }->{ $_ }++;
}
$start = $start->plus_days(1);
# print STDERR "^";
} while ($start->compare($now) < 0);
if ($state eq 'closed') {
my $end = Time::Moment->from_string($issue->{closed_at});
do {
my $ymd = $end->strftime('%F');
$days->{ $ymd }->{ 'resolved' }->{ $type }->{ $priority }->{ $severity }->{ total }++;
$schema->{ 'resolved' }->{ $type }->{ $priority }->{ $severity }->{ total }++;
foreach (keys %$labels) {
$days->{ $ymd }->{ 'resolved' }->{ $type }->{ $priority }->{ $severity }->{ $_ }++;
$schema->{ 'resolved' }->{ $type }->{ $priority }->{ $severity }->{ $_ }++;
}
$end = $end->plus_days(1);
} while ($end->compare($now) < 0);
# print STDERR "v";
}
# print STDERR "\n";
}
print "day,";
foreach my $state (sort keys %{$schema}) {
foreach my $type (grep { /^(bug|feature)$/ } sort keys %{$schema->{$state}}) {
foreach my $priority (grep { /^(p1|p2)$/ } sort keys %{$schema->{$state}->{$type}}) {
foreach my $severity (sort keys %{$schema->{$state}->{$type}->{$priority}}) {
# foreach my $tag (sort keys %{$schema->{$state}->{$type}->{$priority}->{$severity}}) {
# print "\"$type\n$priority\n$severity\n$tag\",";
# }
print "\"$state\n$type\n$priority\n$severity\",";
}
}
}
}
print "\n";
foreach my $day (sort keys %$days) {
print "$day,";
foreach my $state (sort keys %{$schema}) {
foreach my $type (grep { /^(bug|feature)$/ } sort keys %{$schema->{$state}}) {
foreach my $priority (grep { /^(p1|p2)$/ } sort keys %{$schema->{$state}->{$type}}) {
foreach my $severity (sort keys %{$schema->{$state}->{$type}->{$priority}}) {
# foreach my $tag (sort keys %{$schema->{$state}->{$type}->{$priority}->{$severity}}) {
# print $days->{$day}->{$state}->{$type}->{$priority}->{$severity}->{$tag} || 0;
# print ",";
# }
print $days->{$day}->{$state}->{$type}->{$priority}->{$severity}->{total} || 0;
print ",";
}
}
}
}
print "\n";
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env perl
use warnings;
use strict;
use Net::GitHub;
use DateTime;
use DateTime::Format::ISO8601;
use Term::ReadPassword;
# This version of the script emits the total number of bugs open on a given day,
# split by various tags.
#
# If you want instead the cumulative number of open & closed issues on a given day,
# then look at issues-burndown.pl
my $gh = Net::GitHub->new(
login => 'ara4n', pass => read_password("github password: "),
);
$gh->set_default_user_repo('element-hq', 'element-web');
#my @issues = $gh->issue->repos_issues({ state => 'all', milestone => 3 });
my @issues = $gh->issue->repos_issues({ state => 'all' });
while ($gh->issue->has_next_page) {
push @issues, $gh->issue->next_page;
}
# we want:
# day by day:
# split by { open, closed }
# split by { bug, feature, neither }
# each split by { p1, p2, p3, p4, p5, unprioritised } <- priority
# each split by { minor, major, critical, cosmetic, network, no-severity } <- severity
# then split (with overlap between the groups) as { total, tag1, tag2, ... }?
# ...and then all over again split by milestone.
my $days = {};
my $schema = {};
my $now = DateTime->now();
foreach my $issue (@issues) {
next if ($issue->{pull_request});
use Data::Dumper;
print STDERR Dumper($issue);
my @label_list = map { $_->{name} } @{$issue->{labels}};
my $labels = {};
$labels->{$_} = 1 foreach (@label_list);
$labels->{bug}++ if ($labels->{cosmetic} && !$labels->{bug} && !$labels->{feature});
my $extract_labels = sub {
my $label = undef;
foreach (@_) {
$label ||= $_ if (delete $labels->{$_});
}
return $label;
};
my $type = &$extract_labels(qw(bug feature)) || "neither";
my $priority = &$extract_labels(qw(p1 p2 p3 p4 p5)) || "unprioritised";
my $severity = &$extract_labels(qw(minor major critical cosmetic network)) || "no-severity";
my $start = DateTime::Format::ISO8601->parse_datetime($issue->{created_at});
my $end = $issue->{closed_at} ? DateTime::Format::ISO8601->parse_datetime($issue->{closed_at}) : $now;
do {
my $ymd = $start->ymd();
$days->{ $ymd }->{ $type }->{ $priority }->{ $severity }->{ total }++;
$schema->{ $type }->{ $priority }->{ $severity }->{ total }++;
foreach (keys %$labels) {
$days->{ $ymd }->{ $type }->{ $priority }->{ $severity }->{ $_ }++;
$schema->{ $type }->{ $priority }->{ $severity }->{ $_ }++;
}
$start = $start->add(days => 1);
} while (DateTime->compare($start, $end) < 0);
}
print "day,";
foreach my $type (sort keys %{$schema}) {
foreach my $priority (sort keys %{$schema->{$type}}) {
foreach my $severity (sort keys %{$schema->{$type}->{$priority}}) {
# foreach my $tag (sort keys %{$schema->{$type}->{$priority}->{$severity}}) {
# print "\"$type\n$priority\n$severity\n$tag\",";
# }
print "\"$type\n$priority\n$severity\",";
}
}
}
print "\n";
foreach my $day (sort keys %$days) {
print "$day,";
foreach my $type (sort keys %{$schema}) {
foreach my $priority (sort keys %{$schema->{$type}}) {
foreach my $severity (sort keys %{$schema->{$type}->{$priority}}) {
# foreach my $tag (sort keys %{$schema->{$type}->{$priority}->{$severity}}) {
# print $days->{$day}->{$type}->{$priority}->{$severity}->{$tag} || 0;
# print ",";
# }
print $days->{$day}->{$type}->{$priority}->{$severity}->{total} || 0;
print ",";
}
}
}
print "\n";
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -ex
# Creates a layered environment with the full repo for the app and SDKs cloned
# and linked. This gives an element-web dev environment ready to build with
# matching branches of react-sdk's dependencies so that changes can be tested
# in element-web.
# Note that this style is different from the recommended developer setup: this
# file nests js-sdk inside element-web, while the local
# development setup places them all at the same level. We are nesting them here
# because some CI systems do not allow moving to a directory above the checkout
# for the primary repo (element-web in this case).
# Install dependencies
pnpm install --frozen-lockfile $@
# Pass appropriate repo to fetchdep.sh
export PR_ORG=element-hq
export PR_REPO=element-web
js_sdk_dep=$(jq -r '.dependencies["matrix-js-sdk"]' < $(pnpm -w root)/../apps/web/package.json)
# Set up the js-sdk first (unless package.json pins a specific version)
if [ "$js_sdk_dep" = "github:matrix-org/matrix-js-sdk#develop" ]; then
scripts/fetchdep.sh matrix-org matrix-js-sdk develop
if [ -n "$JS_SDK_GITHUB_BASE_REF" ]; then
git -C matrix-js-sdk fetch --depth 1 origin $JS_SDK_GITHUB_BASE_REF
git -C matrix-js-sdk checkout $JS_SDK_GITHUB_BASE_REF
fi
pnpm -C matrix-js-sdk install --frozen-lockfile --ignore-scripts
# Link into into element-web & the monorepo
pnpm -C apps/web link ./matrix-js-sdk
pnpm link ./matrix-js-sdk
else
echo "Skipping matrix-js-sdk fetch and link as package.json pins $js_sdk_dep"
fi
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -e
# If $1 looks like v1.2.3 or v1.2.3-foo, strip the leading v, then print it to stdout
if [[ $1 =~ ^v[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+(-.+)?$ ]]; then
echo ${1:1}
else
echo $1
fi
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env node
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// Utility script to mimic yarn classic `link` behaviour
// to enable rapid development of libraries like matrix-js-sdk using symlinks/directory junctions
// reads .link-config file for DEPENDENCY=PATH values and removes those dependencies from node_modules,
// replacing them with a symlink/directory junction.
// This tool is a helpful substitute to `pnpm link` as that modifies the package.json & pnpm-lock.yaml files.
import * as fs from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { execSync } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, "..", ".link-config");
const nodeModulesPath = join(__dirname, "..", "node_modules");
try {
if (process.env.PLAYWRIGHT_COMMON_DOCKER) process.exit(0); // Skip in docker env
const configFile = await fs.readFile(configPath, "utf-8");
for (const line of configFile.trim().split("\n")) {
if (!line || line.startsWith("#")) continue;
const [dependency, path] = line.split("=");
const dependencyPath = join(nodeModulesPath, dependency);
try {
const stat = await fs.stat(dependencyPath);
if (stat.isSymbolicLink()) {
const linkPath = await fs.readlink(dependencyPath);
if (linkPath === path) {
// already done
continue;
} else {
await fs.unlink(dependencyPath);
}
} else {
await fs.rm(dependencyPath, { recursive: true });
}
console.log(`Linking ${dependency} to ${path}`);
await fs.symlink(path, dependencyPath);
const pkgJson = await fs.readFile(join(path, "package.json"), "utf-8");
const pkgManager = JSON.parse(pkgJson)["packageManager"]?.split("@").at(0) ?? "yarn";
// pnpm install may have wiped out the `node_modules` dir so we have to restore it
execSync(`${pkgManager} install --ignore-scripts --frozen-lockfile`, {
cwd: dependencyPath,
});
} catch (e) {
console.error(`Failed to link ${dependency}`, e);
}
}
} catch {
// Ignore config file not existing
}