Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

89 lines · 2.8 KBRaw History
1/**
2 * GitHub Actions workflow commands (`::name k=v::message` on stdout) and file
3 * commands (`$GITHUB_ENV`, `$GITHUB_OUTPUT`, ... files).
4 */
5
6export interface WorkflowCommand {
7 command: string;
8 properties: Record<string, string>;
9 message: string;
10}
11
12export function unescapeData(s: string): string {
13 return s.replace(/%0D/gi, "\r").replace(/%0A/gi, "\n").replace(/%25/g, "%");
14}
15
16export function unescapeProperty(s: string): string {
17 return s
18 .replace(/%0D/gi, "\r")
19 .replace(/%0A/gi, "\n")
20 .replace(/%3A/gi, ":")
21 .replace(/%2C/gi, ",")
22 .replace(/%25/g, "%");
23}
24
25/** Parses a `::command props::message` line. Returns null if not a command. */
26export function parseCommand(line: string): WorkflowCommand | null {
27 const trimmed = line.replace(/^\s+/, "");
28 if (!trimmed.startsWith("::")) return null;
29 const end = trimmed.indexOf("::", 2);
30 if (end < 0) return null;
31 const head = trimmed.slice(2, end);
32 const message = trimmed.slice(end + 2);
33 const sp = head.indexOf(" ");
34 const command = (sp < 0 ? head : head.slice(0, sp)).trim();
35 if (!command || !/^[A-Za-z0-9_.-]+$/.test(command)) return null;
36 const properties: Record<string, string> = {};
37 if (sp >= 0) {
38 for (const part of head.slice(sp + 1).split(",")) {
39 const eq = part.indexOf("=");
40 if (eq <= 0) continue;
41 properties[part.slice(0, eq).trim()] = unescapeProperty(part.slice(eq + 1));
42 }
43 }
44 return { command, properties, message: unescapeData(message) };
45}
46
47/**
48 * Parses an env-file style file command (GITHUB_ENV / GITHUB_OUTPUT /
49 * GITHUB_STATE): `name=value` lines and `name<<DELIM` heredocs.
50 */
51export function parseKeyValueFile(content: string): Array<[string, string]> {
52 const out: Array<[string, string]> = [];
53 if (!content) return out;
54 const lines = content.split(/\r?\n/);
55 for (let i = 0; i < lines.length; i++) {
56 const line = lines[i]!;
57 if (line === "") continue;
58 const eq = line.indexOf("=");
59 const hd = line.indexOf("<<");
60 if (hd > 0 && (eq < 0 || hd < eq)) {
61 const name = line.slice(0, hd);
62 const delim = line.slice(hd + 2);
63 if (!name || !delim) throw new Error(`Invalid file command format: ${line}`);
64 const buf: string[] = [];
65 let found = false;
66 for (i++; i < lines.length; i++) {
67 if (lines[i] === delim) {
68 found = true;
69 break;
70 }
71 buf.push(lines[i]!);
72 }
73 if (!found) throw new Error(`Matching delimiter not found '${delim}'`);
74 out.push([name, buf.join("\n")]);
75 continue;
76 }
77 if (eq <= 0) throw new Error(`Invalid file command format: ${line}`);
78 out.push([line.slice(0, eq), line.slice(eq + 1)]);
79 }
80 return out;
81}
82
83/** GITHUB_PATH: one directory per line. */
84export function parsePathFile(content: string): string[] {
85 return content
86 .split(/\r?\n/)
87 .map((l) => l.trim())
88 .filter(Boolean);
89}