omp-conductor 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routing.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Eligibility, repo routing and branch naming.
3
+ *
4
+ * Pure by design: every decision here is a function of the issue plus the
5
+ * project config, so the dispatcher's riskiest choice — "which checkout does
6
+ * this issue belong in" — is testable without a tracker, a clone or a network.
7
+ * The module refuses to guess. An issue that names two repos, or none, is
8
+ * handed back as unroutable rather than dispatched somewhere plausible,
9
+ * because a multi-repo request silently taken whole by one worker is exactly
10
+ * the failure this guard exists to prevent.
11
+ */
12
+
13
+ import type { ProjectConfig, ReadyIssue, RepoTarget } from "./types.ts";
14
+
15
+ /** An issue paired with the single checkout its labels unambiguously name. */
16
+ export type Routed = { issue: ReadyIssue; repo: RepoTarget };
17
+
18
+ /**
19
+ * Why an otherwise-eligible issue could not be dispatched. All three are
20
+ * human-fixable label problems, which is why they escalate rather than fail.
21
+ */
22
+ export type UnroutableReason =
23
+ | "no-repo-label"
24
+ | "multiple-repo-labels"
25
+ | "unknown-repo";
26
+
27
+ /**
28
+ * `labels` carries the prefixed labels actually seen, so the escalation can
29
+ * quote them back ("saw repo:api, repo:worker") instead of telling a human to
30
+ * go and look.
31
+ */
32
+ export type Unroutable = {
33
+ issue: ReadyIssue;
34
+ reason: UnroutableReason;
35
+ labels: string[];
36
+ };
37
+
38
+ /** Git refs stay short enough to read in a PR list without wrapping. */
39
+ const MAX_BRANCH_LEN = 60;
40
+
41
+ /**
42
+ * True only when a human has queued the issue and no run already owns it.
43
+ *
44
+ * The state labels are the interlock against double-dispatch across daemon
45
+ * restarts: the tracker, not the local store, is the source of truth for
46
+ * "someone is already on this".
47
+ */
48
+ export function isEligible(issue: ReadyIssue, p: ProjectConfig): boolean {
49
+ // ponytail: label comparison is exact and case-sensitive. Ceiling — a human
50
+ // typing `Agent-Ready` gets silently ignored. Upgrade path: case-fold both
51
+ // sides here, which is the only place labels are matched.
52
+ const labels = new Set(issue.labels);
53
+ if (!labels.has(p.queueLabel)) return false;
54
+ const { inProgress, blocked, failed } = p.stateLabels;
55
+ return !labels.has(inProgress) && !labels.has(blocked) && !labels.has(failed);
56
+ }
57
+
58
+ /**
59
+ * Partition eligible issues into dispatchable and needs-a-human.
60
+ *
61
+ * Ineligible issues appear in neither bucket — they are not this loop's
62
+ * business, and reporting them as unroutable would escalate every in-flight
63
+ * run on every poll.
64
+ */
65
+ export function route(
66
+ issues: ReadyIssue[],
67
+ p: ProjectConfig,
68
+ ): { routed: Routed[]; unroutable: Unroutable[] } {
69
+ const routed: Routed[] = [];
70
+ const unroutable: Unroutable[] = [];
71
+ const { labelPrefix, repos } = p.routing;
72
+
73
+ for (const issue of issues) {
74
+ if (!isEligible(issue, p)) continue;
75
+
76
+ // Deduplicated: a repeated label is one repo, not an ambiguity.
77
+ const matched = [
78
+ ...new Set(issue.labels.filter((l) => l.startsWith(labelPrefix))),
79
+ ];
80
+
81
+ if (matched.length === 0) {
82
+ unroutable.push({ issue, reason: "no-repo-label", labels: matched });
83
+ continue;
84
+ }
85
+ if (matched.length > 1) {
86
+ unroutable.push({
87
+ issue,
88
+ reason: "multiple-repo-labels",
89
+ labels: matched,
90
+ });
91
+ continue;
92
+ }
93
+
94
+ const key = matched[0]!.slice(labelPrefix.length);
95
+ // hasOwn, not truthiness: a `repo:constructor` label would otherwise
96
+ // resolve off Object.prototype and route work into a bogus target.
97
+ if (!Object.hasOwn(repos, key)) {
98
+ unroutable.push({ issue, reason: "unknown-repo", labels: matched });
99
+ continue;
100
+ }
101
+ routed.push({ issue, repo: repos[key]! });
102
+ }
103
+
104
+ return { routed, unroutable };
105
+ }
106
+
107
+ /**
108
+ * `bug` → `fix`, everything else → `feat`.
109
+ *
110
+ * The label may be namespaced (`type:bug`, `kind/bug`), so only its last
111
+ * segment is compared — the same label means the same thing whichever
112
+ * convention a repo uses.
113
+ */
114
+ function inferType(labels: string[]): "fix" | "feat" {
115
+ for (const label of labels) {
116
+ const lower = label.toLowerCase().trim();
117
+ const cut = Math.max(lower.lastIndexOf(":"), lower.lastIndexOf("/"));
118
+ if (lower.slice(cut + 1).trim() === "bug") return "fix";
119
+ }
120
+ return "feat";
121
+ }
122
+
123
+ /**
124
+ * Title → `[a-z0-9-]`, runs of `-` collapsed, ends trimmed.
125
+ *
126
+ * Every character git dislikes in a ref (`.`, `:`, `~`, `^`, `?`, `*`, `[`,
127
+ * `\`, whitespace) is outside the allow-list, so a slug cannot produce an
128
+ * invalid ref — no `..`, no leading or trailing dot, no space.
129
+ */
130
+ function slugify(title: string): string {
131
+ return (
132
+ title
133
+ // Fold accents so "Café crash" keeps its words instead of dissolving.
134
+ .normalize("NFKD")
135
+ .replace(/\p{Diacritic}/gu, "")
136
+ .toLowerCase()
137
+ .replace(/[^a-z0-9]+/g, "-")
138
+ .replace(/^-+|-+$/g, "")
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Deterministic `<type>/<slug>` branch name, capped whole at 60 characters and
144
+ * never ending in `-`.
145
+ *
146
+ * Determinism matters beyond tidiness: a resumed or retried run recomputes the
147
+ * same branch and finds its own work instead of forking a second one.
148
+ */
149
+ export function branchName(issue: ReadyIssue): string {
150
+ const type = inferType(issue.labels);
151
+ // ponytail: a CJK or emoji-only title slugifies to nothing and lands on
152
+ // `issue-<n>`. Ceiling — such branches read as opaque. Upgrade path:
153
+ // transliterate here, or use the tracker's own slug when it exposes one.
154
+ const fallback = `issue-${issue.number}`;
155
+ const budget = Math.max(MAX_BRANCH_LEN - type.length - 1, 1);
156
+ const slug = (slugify(issue.title) || fallback)
157
+ .slice(0, budget)
158
+ .replace(/-+$/, "");
159
+ return `${type}/${slug || fallback.slice(0, budget)}`;
160
+ }