report.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. #!/usr/bin/env bun
  2. /**
  3. * Conflict report generation utilities
  4. */
  5. import { $ } from "bun"
  6. export interface ConflictReport {
  7. timestamp: string
  8. upstreamVersion: string
  9. upstreamCommit: string
  10. baseBranch: string
  11. mergeBranch: string
  12. totalConflicts: number
  13. conflicts: ConflictFile[]
  14. recommendations: string[]
  15. }
  16. export interface ConflictFile {
  17. path: string
  18. type: "markdown" | "package" | "code" | "config" | "i18n" | "tauri" | "script" | "extension" | "web" | "other"
  19. recommendation:
  20. | "keep-ours"
  21. | "keep-theirs"
  22. | "manual"
  23. | "codemod"
  24. | "skip"
  25. | "i18n-transform"
  26. | "take-theirs-transform"
  27. | "tauri-transform"
  28. | "package-transform"
  29. | "script-transform"
  30. | "extension-transform"
  31. | "web-transform"
  32. reason: string
  33. }
  34. /**
  35. * Check if a file is an i18n translation file
  36. */
  37. function isI18nFile(path: string): boolean {
  38. // Match patterns like packages/*/src/i18n/*.ts
  39. return /packages\/[^/]+\/src\/i18n\/[^/]+\.ts$/.test(path) && !path.endsWith("/index.ts")
  40. }
  41. /**
  42. * Check if a file is a Tauri/Desktop config file
  43. */
  44. function isTauriFile(path: string): boolean {
  45. return (
  46. path.includes("packages/desktop/src-tauri/") &&
  47. (path.endsWith(".json") || path.endsWith(".toml") || path.endsWith(".rs") || path.endsWith(".lock"))
  48. )
  49. }
  50. /**
  51. * Check if a file is a script file
  52. */
  53. function isScriptFile(path: string): boolean {
  54. return path.startsWith("script/") || path.includes("/script/")
  55. }
  56. /**
  57. * Check if a file is an extension file
  58. */
  59. function isExtensionFile(path: string): boolean {
  60. return path.includes("packages/extensions/")
  61. }
  62. /**
  63. * Check if a file is a web/docs file
  64. */
  65. function isWebFile(path: string): boolean {
  66. return path.includes("packages/web/src/content/docs/") && path.endsWith(".mdx")
  67. }
  68. /**
  69. * Check if a file should use take-theirs + transform strategy
  70. */
  71. function shouldTakeTheirsTransform(path: string): boolean {
  72. const patterns = [
  73. /^packages\/app\/src\/components\/.*\.tsx$/,
  74. /^packages\/app\/src\/context\/.*\.tsx$/,
  75. /^packages\/app\/src\/pages\/.*\.tsx$/,
  76. /^packages\/ui\/src\/.*\.tsx$/,
  77. /^packages\/desktop\/src\/.*\.ts$/,
  78. /^packages\/app\/e2e\/.*\.ts$/,
  79. /^packages\/app\/script\/.*\.ts$/,
  80. /^github\/index\.ts$/,
  81. /^packages\/slack\/src\/.*\.ts$/,
  82. ]
  83. return patterns.some((p) => p.test(path))
  84. }
  85. /**
  86. * Classify a file based on its path
  87. */
  88. export function classifyFile(path: string): ConflictFile["type"] {
  89. if (isI18nFile(path)) return "i18n"
  90. if (isTauriFile(path)) return "tauri"
  91. if (isScriptFile(path)) return "script"
  92. if (isExtensionFile(path)) return "extension"
  93. if (isWebFile(path)) return "web"
  94. if (path.endsWith(".md")) return "markdown"
  95. if (path.includes("package.json")) return "package"
  96. if (path.endsWith(".ts") || path.endsWith(".tsx") || path.endsWith(".js") || path.endsWith(".jsx")) return "code"
  97. if (
  98. path.endsWith(".json") ||
  99. path.endsWith(".yaml") ||
  100. path.endsWith(".yml") ||
  101. path.endsWith(".toml") ||
  102. path.endsWith(".config.ts")
  103. )
  104. return "config"
  105. return "other"
  106. }
  107. /**
  108. * Check if a file should be skipped (not added from upstream)
  109. */
  110. function shouldSkipFile(path: string, skipPatterns: string[]): boolean {
  111. return skipPatterns.some((pattern) => path === pattern || path.includes(pattern))
  112. }
  113. /**
  114. * Get recommendation for a conflicted file.
  115. * Pass currentContent (our version of the file) to detect kilocode_change markers
  116. * in files that would otherwise be auto-transformed.
  117. */
  118. export function getRecommendation(
  119. path: string,
  120. keepOurs: string[],
  121. skipFiles: string[] = [],
  122. currentContent?: string,
  123. ): { recommendation: ConflictFile["recommendation"]; reason: string } {
  124. // Check if file should be skipped entirely (doesn't exist in Kilo, shouldn't be added)
  125. if (shouldSkipFile(path, skipFiles)) {
  126. return {
  127. recommendation: "skip",
  128. reason: "File should be skipped (does not exist in Kilo fork)",
  129. }
  130. }
  131. // Check if file should always keep ours
  132. if (keepOurs.some((pattern) => path.includes(pattern) || path === pattern)) {
  133. return {
  134. recommendation: "keep-ours",
  135. reason: "File is Kilo-specific and should not be overwritten",
  136. }
  137. }
  138. // Kilo directories should always keep ours
  139. if (path.includes("kilocode") || path.includes("kilo-gateway") || path.includes("kilo-telemetry")) {
  140. return {
  141. recommendation: "keep-ours",
  142. reason: "File is in a Kilo-specific directory",
  143. }
  144. }
  145. const type = classifyFile(path)
  146. // Check for specific auto-transform strategies
  147. if (shouldTakeTheirsTransform(path)) {
  148. // If our version has kilocode_change markers, flag for manual review
  149. if (currentContent?.includes("kilocode_change")) {
  150. return {
  151. recommendation: "manual",
  152. reason: "File has kilocode_change markers — auto-transform skipped, needs manual review",
  153. }
  154. }
  155. return {
  156. recommendation: "take-theirs-transform",
  157. reason: "Branding-only file: take upstream and apply Kilo branding transforms",
  158. }
  159. }
  160. switch (type) {
  161. case "i18n":
  162. // i18n files that have kilocode_change markers need manual review
  163. if (currentContent?.includes("kilocode_change")) {
  164. return {
  165. recommendation: "manual",
  166. reason: "i18n file has kilocode_change markers — auto-transform skipped, needs manual review",
  167. }
  168. }
  169. return {
  170. recommendation: "i18n-transform",
  171. reason: "i18n file: take upstream translations and apply Kilo branding",
  172. }
  173. case "tauri":
  174. if (currentContent?.includes("kilocode_change")) {
  175. return {
  176. recommendation: "manual",
  177. reason: "Tauri config has kilocode_change markers — auto-transform skipped, needs manual review",
  178. }
  179. }
  180. return {
  181. recommendation: "tauri-transform",
  182. reason: "Tauri config: take upstream and apply Kilo branding transforms",
  183. }
  184. case "script":
  185. if (currentContent?.includes("kilocode_change")) {
  186. return {
  187. recommendation: "manual",
  188. reason: "Script file has kilocode_change markers — auto-transform skipped, needs manual review",
  189. }
  190. }
  191. return {
  192. recommendation: "script-transform",
  193. reason: "Script file: take upstream and transform GitHub references",
  194. }
  195. case "extension":
  196. if (currentContent?.includes("kilocode_change")) {
  197. return {
  198. recommendation: "manual",
  199. reason: "Extension file has kilocode_change markers — auto-transform skipped, needs manual review",
  200. }
  201. }
  202. return {
  203. recommendation: "extension-transform",
  204. reason: "Extension file: take upstream and apply Kilo branding",
  205. }
  206. case "web":
  207. if (currentContent?.includes("kilocode_change")) {
  208. return {
  209. recommendation: "manual",
  210. reason: "Web/docs file has kilocode_change markers — auto-transform skipped, needs manual review",
  211. }
  212. }
  213. return {
  214. recommendation: "web-transform",
  215. reason: "Web/docs file: take upstream and apply Kilo branding",
  216. }
  217. case "markdown":
  218. return {
  219. recommendation: "keep-ours",
  220. reason: "Markdown files are typically Kilo-specific documentation",
  221. }
  222. case "package":
  223. if (currentContent?.includes("kilocode_change")) {
  224. return {
  225. recommendation: "manual",
  226. reason: "package.json has kilocode_change markers — auto-transform skipped, needs manual review",
  227. }
  228. }
  229. return {
  230. recommendation: "package-transform",
  231. reason: "Package.json: take upstream, transform names, inject Kilo deps, preserve version",
  232. }
  233. case "code":
  234. return {
  235. recommendation: "manual",
  236. reason: "Code files need manual review for kilocode_change markers",
  237. }
  238. case "config":
  239. return {
  240. recommendation: "manual",
  241. reason: "Config files may have Kilo-specific settings",
  242. }
  243. default:
  244. return {
  245. recommendation: "manual",
  246. reason: "File needs manual review",
  247. }
  248. }
  249. }
  250. /**
  251. * Analyze potential conflicts before merge
  252. */
  253. export async function analyzeConflicts(
  254. upstreamRef: string,
  255. baseBranch: string,
  256. keepOurs: string[],
  257. skipFiles: string[] = [],
  258. ): Promise<ConflictFile[]> {
  259. // Get list of files that differ between branches
  260. // Use quiet to suppress output and nothrow to handle errors
  261. const result = await $`git diff --name-only ${baseBranch}...${upstreamRef}`.quiet().nothrow()
  262. if (result.exitCode !== 0) {
  263. throw new Error(`Failed to analyze conflicts: ${result.stderr.toString()}`)
  264. }
  265. const files = result.stdout
  266. .toString()
  267. .trim()
  268. .split("\n")
  269. .filter((f) => f.length > 0)
  270. const conflicts: ConflictFile[] = []
  271. for (const path of files) {
  272. const type = classifyFile(path)
  273. // Read current file content (our version) to detect kilocode_change markers
  274. const content = await Bun.file(path)
  275. .text()
  276. .catch(() => "")
  277. const { recommendation, reason } = getRecommendation(path, keepOurs, skipFiles, content)
  278. conflicts.push({
  279. path,
  280. type,
  281. recommendation,
  282. reason,
  283. })
  284. }
  285. return conflicts
  286. }
  287. /**
  288. * Generate a markdown report
  289. */
  290. export function generateMarkdownReport(report: ConflictReport): string {
  291. const lines: string[] = [
  292. "# Upstream Merge Conflict Report",
  293. "",
  294. `Generated: ${report.timestamp}`,
  295. "",
  296. "## Summary",
  297. "",
  298. `- **Upstream Version**: ${report.upstreamVersion}`,
  299. `- **Upstream Commit**: \`${report.upstreamCommit.slice(0, 8)}\``,
  300. `- **Base Branch**: ${report.baseBranch}`,
  301. `- **Merge Branch**: ${report.mergeBranch}`,
  302. `- **Total Files Changed**: ${report.totalConflicts}`,
  303. "",
  304. "## Files by Recommendation",
  305. "",
  306. ]
  307. const byRecommendation = new Map<string, ConflictFile[]>()
  308. for (const conflict of report.conflicts) {
  309. const list = byRecommendation.get(conflict.recommendation) || []
  310. list.push(conflict)
  311. byRecommendation.set(conflict.recommendation, list)
  312. }
  313. const order: ConflictFile["recommendation"][] = [
  314. "skip",
  315. "i18n-transform",
  316. "take-theirs-transform",
  317. "tauri-transform",
  318. "package-transform",
  319. "script-transform",
  320. "extension-transform",
  321. "web-transform",
  322. "keep-ours",
  323. "codemod",
  324. "keep-theirs",
  325. "manual",
  326. ]
  327. for (const rec of order) {
  328. const files = byRecommendation.get(rec)
  329. if (!files || files.length === 0) continue
  330. const titleMap: Record<ConflictFile["recommendation"], string> = {
  331. skip: "Skip (Auto-Remove)",
  332. "i18n-transform": "i18n Transform (Auto-Apply Kilo Branding)",
  333. "take-theirs-transform": "Take Upstream + Kilo Branding (Auto)",
  334. "tauri-transform": "Tauri Config Transform (Auto)",
  335. "package-transform": "Package.json Transform (Auto)",
  336. "script-transform": "Script Transform (Auto)",
  337. "extension-transform": "Extension Transform (Auto)",
  338. "web-transform": "Web/Docs Transform (Auto)",
  339. "keep-ours": "Keep Kilo Version (Ours)",
  340. "keep-theirs": "Take Upstream Version (Theirs)",
  341. codemod: "Apply Codemod",
  342. manual: "Manual Review Required",
  343. }
  344. const title = titleMap[rec]
  345. lines.push(`### ${title}`)
  346. lines.push("")
  347. for (const file of files) {
  348. lines.push(`- \`${file.path}\` (${file.type})`)
  349. lines.push(` - ${file.reason}`)
  350. }
  351. lines.push("")
  352. }
  353. if (report.recommendations.length > 0) {
  354. lines.push("## Recommendations")
  355. lines.push("")
  356. for (const rec of report.recommendations) {
  357. lines.push(`- ${rec}`)
  358. }
  359. lines.push("")
  360. }
  361. return lines.join("\n")
  362. }
  363. /**
  364. * Save report to file
  365. */
  366. export async function saveReport(report: ConflictReport, path: string): Promise<void> {
  367. const markdown = generateMarkdownReport(report)
  368. await Bun.write(path, markdown)
  369. }