kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import type { GitHubConfig } from "../config";
2
3function slugify(text: string): string {
4 return text
5 .toLowerCase()
6 .replace(/[^a-z0-9]+/g, "-")
7 .replace(/^-|-$/g, "")
8 .slice(0, 50);
9}
10
11export function generateBranchName(
12 pattern: string,
13 projectSlug: string,
14 taskNumber: number,
15 taskTitle: string,
16): string {
17 return pattern
18 .replace("{slug}", projectSlug.toLowerCase())
19 .replace("{number}", taskNumber.toString())
20 .replace("{title}", slugify(taskTitle));
21}
22
23export function createBranchRegex(
24 pattern: string,
25 projectSlug: string,
26): RegExp {
27 const escapedPattern = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
29 const regexPattern = escapedPattern
30 .replace("\\{slug\\}", projectSlug.toLowerCase())
31 .replace("\\{number\\}", "(\\d+)")
32 .replace("\\{title\\}", "([a-z0-9-]+)");
33
34 // Allow optional suffix after the pattern (e.g., lif-3-part-1)
35 return new RegExp(`^${regexPattern}(?:-.*)?$`, "i");
36}
37
38export function extractTaskNumberFromBranch(
39 branchName: string,
40 config: GitHubConfig,
41 projectSlug: string,
42): number | null {
43 if (config.customBranchRegex) {
44 try {
45 const customRegex = new RegExp(config.customBranchRegex, "i");
46 const match = branchName.match(customRegex);
47 if (match?.[1]) {
48 const num = Number.parseInt(match[1], 10);
49 if (!Number.isNaN(num)) return num;
50 }
51 } catch {
52 console.error("Invalid custom branch regex:", config.customBranchRegex);
53 }
54 return null;
55 }
56
57 const pattern = config.branchPattern || "{slug}-{number}";
58 const regex = createBranchRegex(pattern, projectSlug);
59 const match = branchName.match(regex);
60
61 if (match?.[1]) {
62 const num = Number.parseInt(match[1], 10);
63 if (!Number.isNaN(num)) return num;
64 }
65
66 return null;
67}
68
69export function extractTaskNumberFromPRTitle(title: string): number | null {
70 const patterns = [
71 /\[(\d+)\]/,
72 /#(\d+)/,
73 /\((\d+)\)/,
74 /^(\d+)[:\-\s]/,
75 /task[:\-\s]*(\d+)/i,
76 ];
77
78 for (const pattern of patterns) {
79 const match = title.match(pattern);
80 if (match?.[1]) {
81 const num = Number.parseInt(match[1], 10);
82 if (!Number.isNaN(num)) return num;
83 }
84 }
85
86 return null;
87}
88
89export function extractTaskNumberFromPRBody(body: string): number | null {
90 const patterns = [
91 /task[:\-\s#]*(\d+)/i,
92 /closes[:\-\s#]*(\d+)/i,
93 /fixes[:\-\s#]*(\d+)/i,
94 /resolves[:\-\s#]*(\d+)/i,
95 ];
96
97 for (const pattern of patterns) {
98 const match = body.match(pattern);
99 if (match?.[1]) {
100 const num = Number.parseInt(match[1], 10);
101 if (!Number.isNaN(num)) return num;
102 }
103 }
104
105 return null;
106}
107
108export function extractTaskNumber(
109 branchName: string,
110 prTitle: string | undefined,
111 prBody: string | undefined,
112 config: GitHubConfig,
113 projectSlug: string,
114): number | null {
115 const fromBranch = extractTaskNumberFromBranch(
116 branchName,
117 config,
118 projectSlug,
119 );
120 if (fromBranch) return fromBranch;
121
122 if (prTitle) {
123 const fromTitle = extractTaskNumberFromPRTitle(prTitle);
124 if (fromTitle) return fromTitle;
125 }
126
127 if (prBody) {
128 const fromBody = extractTaskNumberFromPRBody(prBody);
129 if (fromBody) return fromBody;
130 }
131
132 return null;
133}