diff --git a/.gitignore b/.gitignore index d9fbb4b..057e747 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store .env.local +node_modules/ diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..479a1ed --- /dev/null +++ b/bun.lock @@ -0,0 +1,145 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "eternity-loop", + "dependencies": { + "@inquirer/prompts": "^7.0.0", + "@linear/sdk": "^29.0.0", + "@octokit/graphql": "^8.0.0", + "@octokit/rest": "^21.0.0", + }, + "devDependencies": { + "bun-types": "^1.2.0", + }, + }, + }, + "packages": { + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + + "@inquirer/checkbox": ["@inquirer/checkbox@4.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], + + "@inquirer/editor": ["@inquirer/editor@4.2.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/external-editor": "^1.0.3", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ=="], + + "@inquirer/expand": ["@inquirer/expand@4.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew=="], + + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/input": ["@inquirer/input@4.3.1", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g=="], + + "@inquirer/number": ["@inquirer/number@3.0.23", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg=="], + + "@inquirer/password": ["@inquirer/password@4.0.23", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA=="], + + "@inquirer/prompts": ["@inquirer/prompts@7.10.1", "", { "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", "@inquirer/editor": "^4.2.23", "@inquirer/expand": "^4.0.23", "@inquirer/input": "^4.3.1", "@inquirer/number": "^3.0.23", "@inquirer/password": "^4.0.23", "@inquirer/rawlist": "^4.1.11", "@inquirer/search": "^3.2.2", "@inquirer/select": "^4.4.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg=="], + + "@inquirer/rawlist": ["@inquirer/rawlist@4.1.11", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw=="], + + "@inquirer/search": ["@inquirer/search@3.2.2", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA=="], + + "@inquirer/select": ["@inquirer/select@4.4.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/core": "^10.3.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w=="], + + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + + "@linear/sdk": ["@linear/sdk@29.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0", "isomorphic-unfetch": "^3.1.0" } }, "sha512-c3hmWW4L0sIL2Aaf0wt7LhfIc58AHdsHcQOCxWZC3nXb0dDvrG6/g9WFXM5k2dYSgPJHcZ21O/+KlI/KTOkK1A=="], + + "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], + + "@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="], + + "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], + + "@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@11.6.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@5.3.1", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@13.5.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw=="], + + "@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="], + + "@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], + + "@octokit/rest": ["@octokit/rest@21.1.1", "", { "dependencies": { "@octokit/core": "^6.1.4", "@octokit/plugin-paginate-rest": "^11.4.2", "@octokit/plugin-request-log": "^5.3.1", "@octokit/plugin-rest-endpoint-methods": "^13.3.0" } }, "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg=="], + + "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + + "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], + + "bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="], + + "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], + + "graphql": ["graphql@15.10.1", "", {}, "sha512-BL/Xd/T9baO6NFzoMpiMD7YUZ62R6viR5tp/MULVEnbYJXZA//kRNW7J0j1w/wXArgL0sCxhDfK5dczSKn3+cg=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isomorphic-unfetch": ["isomorphic-unfetch@3.1.0", "", { "dependencies": { "node-fetch": "^2.6.1", "unfetch": "^4.2.0" } }, "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q=="], + + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unfetch": ["unfetch@4.2.0", "", {}, "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a4b2d14 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "eternity-loop", + "private": true, + "type": "module", + "dependencies": { + "@linear/sdk": "^29.0.0", + "@octokit/rest": "^21.0.0", + "@octokit/graphql": "^8.0.0", + "@inquirer/prompts": "^7.0.0" + }, + "bin": { + "eternity-loop": "./scripts/eternity-loop/index.ts" + }, + "devDependencies": { + "bun-types": "^1.2.0" + } +} diff --git a/scripts/eternity-loop.sh b/scripts/eternity-loop.sh deleted file mode 100755 index fcafb17..0000000 --- a/scripts/eternity-loop.sh +++ /dev/null @@ -1,1443 +0,0 @@ -#!/bin/bash -# Eternity Loop - Autonomous task runner that pulls PRDs from Linear -# Usage: eternity-loop.sh [--tool amp|claude] [--max-iterations N] -# -# Runs in its own git worktree and tmux session called "eternity-loop". -# If already running, attaches to the existing session. -# -# Workflow: -# 1. Setup: detect Linear team + project (via Linear GraphQL API) -# 2. Priority 1: Check for "In Review"/"In Progress" issues with new PR comments (via Linear API) -# 3. Priority 2: Check for CI failures on existing PRs and attempt auto-fix -# 4. Priority 3: Fetch next "Todo" issue via Linear API, mark in progress, create branch, generate prd.json -# 5. Run ralph-loop.sh to execute the PRD -# 6. Finalize: push branch and create GitHub PR (or push fixes to existing PR) -# 7. Repeat - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_NAME="$(basename "$(git rev-parse --show-toplevel 2>/dev/null || pwd)")" -TMUX_SESSION="eternity-loop-${PROJECT_NAME}" -WORKTREE_NAME="eternity-loop" - -# Load .env from standard candidate paths (first match wins) -# Priority: .env.local > .env, checked in: cwd > script dir > repo root (when in worktree) -load_env() { - for _env_candidate in \ - "$(pwd)/.env.local" \ - "$(pwd)/.env" \ - "$SCRIPT_DIR/.env.local" \ - "$SCRIPT_DIR/.env" \ - "${ETERNITY_LOOP_REPO_ROOT:-}/.env.local" \ - "${ETERNITY_LOOP_REPO_ROOT:-}/.env" \ - "${ETERNITY_LOOP_REPO_ROOT:-}/scripts/.env.local" \ - "${ETERNITY_LOOP_REPO_ROOT:-}/scripts/.env"; do - [ -z "$_env_candidate" ] && continue - if [ -f "$_env_candidate" ]; then - set -a; source "$_env_candidate"; set +a - break - fi - done -} - -# --- Worktree + tmux bootstrap --- -# If not already inside the eternity-loop tmux session, set up worktree and launch -if [ -z "${ETERNITY_LOOP_INSIDE:-}" ]; then - REPO_ROOT="$(git rev-parse --show-toplevel)" - WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$WORKTREE_NAME" - - # Load .env in bootstrap phase so LINEAR_API_KEY can be passed to tmux - load_env - - # Determine the main branch name - MAIN_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') || true - [ -z "$MAIN_BRANCH" ] && MAIN_BRANCH="main" - - # Kill existing tmux session if running - if tmux has-session -t "$TMUX_SESSION" 2>/dev/null; then - echo "Killing existing tmux session '$TMUX_SESSION'..." - tmux kill-session -t "$TMUX_SESSION" - fi - - # Remove existing worktree and create fresh - if [ -d "$WORKTREE_PATH" ]; then - echo "Removing existing worktree at $WORKTREE_PATH..." - git worktree remove --force "$WORKTREE_PATH" 2>/dev/null || rm -rf "$WORKTREE_PATH" - fi - mkdir -p "$(dirname "$WORKTREE_PATH")" - git fetch origin 2>/dev/null || true - echo "Creating git worktree at $WORKTREE_PATH (branch: $MAIN_BRANCH)..." - git worktree add "$WORKTREE_PATH" --detach "origin/$MAIN_BRANCH" - - # Launch a new tmux session running this script inside the worktree - # After the script exits, clean up the worktree - echo "Starting tmux session '$TMUX_SESSION' in worktree $WORKTREE_PATH..." - exec tmux new-session -s "$TMUX_SESSION" \ - "cd '$WORKTREE_PATH' && ETERNITY_LOOP_INSIDE=1 ETERNITY_LOOP_WORKTREE='$WORKTREE_PATH' ETERNITY_LOOP_REPO_ROOT='$REPO_ROOT' LINEAR_API_KEY='${LINEAR_API_KEY:-}' '$SCRIPT_DIR/eternity-loop.sh' $*; echo 'Eternity loop exited. Press enter to close.'; read" -fi - -# --- From here on we're inside the tmux session, in the worktree --- - -# Clean up the worktree on exit -cleanup_worktree() { - if [ -n "${ETERNITY_LOOP_WORKTREE:-}" ] && [ -n "${ETERNITY_LOOP_REPO_ROOT:-}" ]; then - echo "" - echo "Cleaning up worktree at $ETERNITY_LOOP_WORKTREE..." - cd "$ETERNITY_LOOP_REPO_ROOT" - git worktree remove --force "$ETERNITY_LOOP_WORKTREE" 2>/dev/null || rm -rf "$ETERNITY_LOOP_WORKTREE" - echo "Worktree removed." - fi -} - -# Ensure Ctrl-C kills the entire process tree and cleans up worktree -trap 'echo ""; echo "Interrupted. Cleaning up..."; cleanup_worktree; kill 0; exit 130' INT TERM -trap 'cleanup_worktree' EXIT - -POLL_INTERVAL=120 -TOOL="claude" -MAX_ITERATIONS=50 - -# Timestamped logging -log() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" -} -log_err() { - echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2 -} - -# Load .env if present (for LINEAR_API_KEY) -load_env - -if [ -z "${LINEAR_API_KEY:-}" ]; then - log "ERROR: LINEAR_API_KEY is not set. Set it in your shell environment or in a .env file (project root or scripts directory)." - exit 1 -fi - -linear_graphql() { - local query="$1" - local variables="${2:-"{}"}" - [ -z "$variables" ] && variables="{}" - local payload - payload="$(jq -n --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')" - log_err "[linear-api] Request: $(echo "$payload" | jq -c '.variables' 2>/dev/null || echo "$payload")" - local response - response=$(curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $LINEAR_API_KEY" \ - -d "$payload") - log_err "[linear-api] Response: $(echo "$response" | head -c 1000)" - echo "$response" -} - -# Extract the first valid JSON object from mixed text (handles multiline, nested braces) -extract_json_object() { - python3 -c " -import sys, json -text = sys.stdin.read() -depth = 0 -start = None -for i, ch in enumerate(text): - if ch == '{' and depth == 0: - start = i - depth = 1 - elif ch == '{': - depth += 1 - elif ch == '}' and depth > 0: - depth -= 1 - if depth == 0 and start is not None: - candidate = text[start:i+1] - try: - parsed = json.loads(candidate) - if isinstance(parsed, dict): - print(json.dumps(parsed)) - sys.exit(0) - except json.JSONDecodeError: - start = None - continue -sys.exit(1) -" -} - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --tool) - TOOL="$2" - shift 2 - ;; - --tool=*) - TOOL="${1#*=}" - shift - ;; - --max-iterations) - MAX_ITERATIONS="$2" - shift 2 - ;; - --max-iterations=*) - MAX_ITERATIONS="${1#*=}" - shift - ;; - *) - shift - ;; - esac -done - -# Settings directory lives in the root repo (not worktree) so it persists across sessions -SETTINGS_DIR="${ETERNITY_LOOP_REPO_ROOT:-$(pwd)}/.eternity-loop" -SETTINGS_FILE="$SETTINGS_DIR/settings.json" - -# --- Helpers --- - -get_setting() { - jq -r ".$1 // empty" "$SETTINGS_FILE" -} - -# Extract common fields from issue JSON, sets: issue_id, issue_title, issue_url, branch_name -# Writes JSON to a temp file to avoid pipe/shell-expansion issues with special chars in descriptions -parse_issue_fields() { - local _pif_tmp - _pif_tmp=$(mktemp) - echo "$1" > "$_pif_tmp" - issue_id=$(jq -r '.identifier // .id' "$_pif_tmp") || true - issue_title=$(jq -r '.title' "$_pif_tmp") || true - issue_url=$(jq -r '.url // ""' "$_pif_tmp") || true - branch_name=$(jq -r '.branchName // empty' "$_pif_tmp") || true - rm -f "$_pif_tmp" - [ -z "$branch_name" ] && branch_name="ralph/${issue_id}" - log_err "[parse_issue_fields] id=$issue_id branch=$branch_name" -} - -# Build a Linear issue filter for a team, optionally scoped to a project -build_issue_filter() { - local team_id="$1" - local project_id="${2:-}" - local extra_filter="${3:-}" - local filter - filter=$(jq -n --arg tid "$team_id" '{ - team: {id: {eq: $tid}} - }') - if [ -n "$extra_filter" ]; then - filter=$(echo "$filter" | jq --argjson extra "$extra_filter" '. + $extra') - fi - if [ -n "$project_id" ]; then - filter=$(echo "$filter" | jq --arg pid "$project_id" '. + {project: {id: {eq: $pid}}}') - fi - echo "$filter" -} - -# Verify prd.json was created and log its contents -verify_prd() { - local prd_path="$1" - local label="$2" - if [ ! -f "$prd_path" ]; then - log_err "[$label] ERROR: prd.json was not generated at $prd_path" - return 1 - fi - log_err "[$label] prd.json generated successfully." - log_err "[$label] PRD contents:" - jq '.' "$prd_path" >&2 2>/dev/null || cat "$prd_path" >&2 -} - -# Load ralph SKILL.md guidelines for PRD generation prompts -RALPH_SKILL_GUIDELINES=$(cat "$SCRIPT_DIR/../general/skills/ralph/SKILL.md" 2>/dev/null || echo "") -PROMPTS_DIR="$SCRIPT_DIR/eternity-loop-prompts" -if [ ! -d "$PROMPTS_DIR" ]; then - log "WARNING: Prompts directory not found at $PROMPTS_DIR" -fi - -# Helper to read a prompt file with fallback -read_prompt() { - if [ ! -f "$PROMPTS_DIR/$1" ]; then - log_err "WARNING: Prompt file not found: $PROMPTS_DIR/$1" - fi - cat "$PROMPTS_DIR/$1" 2>/dev/null || echo "# $1 (prompt file not found)" -} - -save_settings() { - local team_id="$1" - local project_id="$2" - local work_dir="$3" - mkdir -p "$SETTINGS_DIR" - cat > "$SETTINGS_FILE" <&2 - echo "$prompt" >&2 - echo "" >&2 - for i in $(seq 0 $((count - 1))); do - local name id - name=$(echo "$json_array" | jq -r ".[$i].$name_field") - id=$(echo "$json_array" | jq -r ".[$i].$id_field") - echo " $((i + 1))) $name ($id)" >&2 - done - echo "" >&2 - - local choice - while true; do - read -rp "Enter number (1-$count): " choice - if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "$count" ]; then - local idx=$((choice - 1)) - echo "$json_array" | jq -c ".[$idx]" - return 0 - fi - echo "Invalid choice. Please enter a number between 1 and $count." >&2 - done -} - -ensure_settings() { - mkdir -p "$SETTINGS_DIR" - - local TEAM_ID="" - local PROJECT_ID="" - local WORK_DIR="" - - # Load existing settings if available - if [ -f "$SETTINGS_FILE" ]; then - TEAM_ID=$(jq -r '.teamId // empty' "$SETTINGS_FILE" 2>/dev/null) || true - PROJECT_ID=$(jq -r '.projectId // empty' "$SETTINGS_FILE" 2>/dev/null) || true - WORK_DIR=$(jq -r '.workingDirectory // empty' "$SETTINGS_FILE" 2>/dev/null) || true - fi - - # If team or project missing, fetch from Linear and ask user to choose - if [ -z "$TEAM_ID" ] || [ -z "$PROJECT_ID" ]; then - log "Settings incomplete. Fetching from Linear..." - local setup_json - setup_json=$(setup_linear) - - if [ -z "$setup_json" ]; then - log "ERROR: Could not fetch teams/projects from Linear." - exit 1 - fi - - # Select team - if [ -z "$TEAM_ID" ]; then - local teams - teams=$(echo "$setup_json" | jq -c '.teams // []') - local team_count - team_count=$(echo "$teams" | jq 'length') - - if [ "$team_count" -eq 1 ]; then - TEAM_ID=$(echo "$teams" | jq -r '.[0].id') - local team_name - team_name=$(echo "$teams" | jq -r '.[0].name') - log "Auto-selected team: $team_name ($TEAM_ID)" - else - local chosen_team - chosen_team=$(pick_from_list "Select a Linear team:" "$teams" "id" "name") - TEAM_ID=$(echo "$chosen_team" | jq -r '.id') - local team_name - team_name=$(echo "$chosen_team" | jq -r '.name') - log "Selected team: $team_name ($TEAM_ID)" - fi - fi - - # Always ask user to confirm project - if [ -z "$PROJECT_ID" ]; then - local projects - projects=$(echo "$setup_json" | jq -c '.projects // []') - - local chosen_project - chosen_project=$(pick_from_list "Select a Linear project:" "$projects" "id" "name") - PROJECT_ID=$(echo "$chosen_project" | jq -r '.id') - local project_name - project_name=$(echo "$chosen_project" | jq -r '.name') - log "Selected project: $project_name ($PROJECT_ID)" - fi - fi - - # Self-repair: fix working directory if missing or relative - if [ -z "$WORK_DIR" ] || [[ "$WORK_DIR" != /* ]]; then - WORK_DIR="$(pwd)" - log "Working directory set to: $WORK_DIR" - fi - - save_settings "$TEAM_ID" "$PROJECT_ID" "$WORK_DIR" -} - -clean_working_tree() { - local work_dir="$1" - cd "$work_dir" - log "[git] Cleaning working tree (dropping uncommitted/untracked files)..." - git checkout -- . 2>/dev/null || true - git clean -fd 2>/dev/null || true - log "[git] Working tree clean." -} - -ensure_main_branch() { - local work_dir="$1" - cd "$work_dir" - log "[git] Ensuring main branch is up-to-date..." - log "[git] Current branch: $(git branch --show-current)" - clean_working_tree "$work_dir" - git fetch origin - git checkout --detach "origin/main" 2>/dev/null || git checkout --detach "origin/master" - log "[git] On $(git branch --show-current), latest: $(git log -1 --format='%h %s')" -} - -# --- Start task: fetch issue via Linear API, mark in progress, generate prd.json via Claude --- - -start_task() { - local team_id="$1" - local work_dir="$2" - local project_id="${3:-}" - local ralph_dir="$work_dir/scripts/ralph" - - log_err "[start-task] Fetching next 'Todo' task..." - log_err "[start-task] Team: $team_id, Project: ${project_id:-}" - - # 1. Fetch Todo issues with prd label via GraphQL - local filter - filter=$(build_issue_filter "$team_id" "$project_id" '{"state": {"name": {"eq": "Todo"}}, "labels": {"some": {"name": {"eq": "prd"}}}}') - - local query='query($filter: IssueFilter!) { - issues(filter: $filter, first: 1, orderBy: createdAt) { - nodes { id identifier title description url branchName } - } - }' - local variables - variables=$(jq -n --argjson f "$filter" '{filter: $f}') - log_err "[start-task] Querying Linear API..." - local result - result=$(linear_graphql "$query" "$variables") - log_err "[start-task] Linear API response: $(echo "$result" | head -c 500)" - - local issue - issue=$(echo "$result" | jq '.data.issues.nodes[0] // empty') - if [ -z "$issue" ] || [ "$issue" = "null" ]; then - log_err "[start-task] No 'Todo' issues found in Linear." - return 1 - fi - - local issue_uuid issue_id issue_title issue_branch issue_desc issue_url - issue_uuid=$(echo "$issue" | jq -r '.id') - issue_id=$(echo "$issue" | jq -r '.identifier') - issue_title=$(echo "$issue" | jq -r '.title') - issue_branch=$(echo "$issue" | jq -r '.branchName // empty') - issue_desc=$(echo "$issue" | jq -r '.description // ""') - issue_url=$(echo "$issue" | jq -r '.url') - log_err "[start-task] Found: $issue_id - $issue_title (branch: $issue_branch)" - - # 2. Update status to "In Progress" via GraphQL - local in_progress_result - in_progress_result=$(linear_graphql 'query($teamId: ID!) { - teams(filter: {id: {eq: $teamId}}) { - nodes { states { nodes { id name } } } - } - }' "$(jq -n --arg t "$team_id" '{teamId: $t}')") - local in_progress_id - in_progress_id=$(echo "$in_progress_result" | jq -r '(.data.teams.nodes[0].states.nodes // [])[] | select(.name == "In Progress") | .id') - - if [ -n "$in_progress_id" ]; then - linear_graphql 'mutation($id: String!, $stateId: String!) { - issueUpdate(id: $id, input: {stateId: $stateId}) { success } - }' "$(jq -n --arg id "$issue_uuid" --arg sid "$in_progress_id" '{id: $id, stateId: $sid}')" > /dev/null - log_err "[start-task] Status updated to 'In Progress'" - fi - - # 3. Generate prd.json via Claude using ralph skill guidelines - mkdir -p "$ralph_dir" - cd "$work_dir" - claude --dangerously-skip-permissions --print -p "$(read_prompt create-prd.md) - -Save to: $ralph_dir/prd.json -branchName: $issue_branch - -## Linear Issue -- ID: $issue_id -- Title: $issue_title -- Description: $issue_desc -- Branch name: $issue_branch -- URL: $issue_url - -$RALPH_SKILL_GUIDELINES" >&2 2>&1 || true - - verify_prd "$ralph_dir/prd.json" "start-task" || return 1 - - echo "$issue" | jq -c '.' - return 0 -} - -# --- Finalize task (create PR via Claude) --- - -finalize_task() { - local work_dir="$1" - local issue_json="$2" - local is_draft="$3" - - local issue_id issue_title issue_url branch_name - parse_issue_fields "$issue_json" - - local draft_flag="" - local status_label="ready for review" - if [ "$is_draft" = "true" ]; then - draft_flag="--draft" - status_label="draft (incomplete)" - fi - - log_err "[finalize] Creating $status_label PR for $issue_id: $issue_title" - log_err "[finalize] Linear URL: $issue_url" - - cd "$work_dir" - - # Push the branch - local branch - branch=$(git branch --show-current) - log_err "[finalize] Pushing branch $branch to origin..." - git push -u origin "$branch" 2>/dev/null || git push origin "$branch" - log_err "[finalize] Branch pushed. Invoking Claude to create PR..." - - claude --dangerously-skip-permissions --print -p "$(read_prompt create-pr.md) - -Title: $issue_id: $issue_title -$( [ "$is_draft" = "true" ] && echo "Make it a DRAFT PR with: gh pr create --draft" || echo "Make it a regular PR with: gh pr create" ) -Linear issue: $issue_url" 2>&1 | tee /dev/stderr || true - - log_err "[finalize] PR created for $issue_id." -} - -# --- Review Handling: Check "In Review" issues for new PR feedback --- - -# Check if a PR has new human comments since the last commit -# Checks: review comments (inline), issue comments (top-level), and review bodies -# Returns 0 if there are new comments, 1 otherwise -# Outputs the PR number on success -check_pr_has_new_human_comments() { - local work_dir="$1" - local branch_name="$2" - - cd "$work_dir" - - # Find the PR for this branch - log_err " [review-check] Looking for PR with head branch: $branch_name" - local pr_number - pr_number=$(gh pr list --head "$branch_name" --json number --jq '.[0].number' 2>/dev/null) || true - if [ -z "$pr_number" ]; then - log_err " [review-check] No PR found for branch $branch_name" - return 1 - fi - log_err " [review-check] Found PR #$pr_number for branch $branch_name" - - # Get the latest commit date on the branch - local latest_commit_date - latest_commit_date=$(TZ=UTC git log -1 --format="%ad" --date=format-local:'%Y-%m-%dT%H:%M:%SZ' "origin/$branch_name" 2>/dev/null) || true - if [ -z "$latest_commit_date" ]; then - log_err " [review-check] Could not determine latest commit date for origin/$branch_name" - return 1 - fi - log_err " [review-check] Latest commit on origin/$branch_name: $latest_commit_date" - - # Common jq filter to exclude bots (for REST API endpoints) - local human_filter=".user.login != \"copilot\" and .user.login != \"github-actions[bot]\" and .user.type != \"Bot\"" - - # Check inline review comments - only count unresolved, non-outdated threads via GraphQL - local new_review_comments=0 - local repo_nwo - repo_nwo=$(gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null) || repo_nwo="" - local repo_owner="${repo_nwo%%/*}" - local repo_name="${repo_nwo##*/}" - - if [ -n "$repo_owner" ] && [ -n "$repo_name" ]; then - new_review_comments=$(gh api graphql -f query=' - query($owner: String!, $name: String!, $pr: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - nodes { - isResolved - isOutdated - comments(first: 50) { - nodes { - author { login } - body - createdAt - } - } - } - } - } - } - } - ' -f owner="$repo_owner" -f name="$repo_name" -F pr="$pr_number" 2>/dev/null | jq --arg cutoff "$latest_commit_date" ' - [(.data.repository.pullRequest.reviewThreads.nodes // [])[] - | select(.isResolved == false and .isOutdated == false) - | (.comments.nodes // [])[] - | select(.createdAt > $cutoff - and (.author.login != "copilot" and .author.login != "github-actions[bot]") - and (.body | startswith("🤖 **eternity-loop bot:**") | not)) - ] | length - ' 2>/dev/null) || true - fi - log_err " [review-check] New human inline comments (unresolved): ${new_review_comments:-0}" - - # Check top-level issue comments (exclude bot replies by body prefix) - local new_issue_comments - new_issue_comments=$(gh api "repos/{owner}/{repo}/issues/$pr_number/comments" --jq " - [.[] | select($human_filter and .created_at > \"$latest_commit_date\" and (.body | startswith(\"🤖 **eternity-loop bot:**\") | not))] | length - " 2>/dev/null) || true - log_err " [review-check] New human top-level comments: ${new_issue_comments:-0}" - - # Check review submissions with body text (exclude bot replies by body prefix) - local new_reviews - new_reviews=$(gh api "repos/{owner}/{repo}/pulls/$pr_number/reviews" --jq " - [.[] | select($human_filter and .body != null and .body != \"\" and .submitted_at > \"$latest_commit_date\" and (.body | startswith(\"🤖 **eternity-loop bot:**\") | not))] | length - " 2>/dev/null) || true - log_err " [review-check] New human review submissions: ${new_reviews:-0}" - - local total=$(( ${new_review_comments:-0} + ${new_issue_comments:-0} + ${new_reviews:-0} )) - log_err " [review-check] Total new human comments: $total" - - if [ "$total" -gt 0 ]; then - echo "$pr_number" - return 0 - fi - return 1 -} - -# Check if a PR has CI failures (and is eligible for auto-fix) -# Returns 0 if there are actionable CI failures, 1 otherwise -# Outputs the PR number on success -# Loop prevention: -# 1. Skip if latest commit message starts with "fix(ci):" (already attempted) -# 2. Tracking file maps {branch: headSHA} — skip if HEAD hasn't changed since last attempt -# 3. Pending checks gate — skip if any checks are still pending -check_pr_has_ci_failures() { - local work_dir="$1" - local branch_name="$2" - - cd "$work_dir" - - # Find the PR for this branch - log_err " [ci-check] Looking for PR with head branch: $branch_name" - local pr_number - pr_number=$(gh pr list --head "$branch_name" --json number --jq '.[0].number' 2>/dev/null) || true - if [ -z "$pr_number" ]; then - log_err " [ci-check] No PR found for branch $branch_name" - return 1 - fi - log_err " [ci-check] Found PR #$pr_number for branch $branch_name" - - # Loop prevention layer 1: check if latest commit starts with "fix(ci):" - local latest_commit_msg - latest_commit_msg=$(git log -1 --format="%s" "origin/$branch_name" 2>/dev/null) || true - if [[ "$latest_commit_msg" == fix\(ci\):* ]]; then - log_err " [ci-check] Latest commit already a CI fix attempt: '$latest_commit_msg'. Skipping." - return 1 - fi - - # Loop prevention layer 2: tracking file check - local tracking_file="$SETTINGS_DIR/ci-fix-tracking.json" - if [ -f "$tracking_file" ]; then - local head_sha - head_sha=$(git rev-parse "origin/$branch_name" 2>/dev/null) || true - if [ -n "$head_sha" ]; then - local tracked_sha - tracked_sha=$(jq -r --arg b "$branch_name" '.[$b] // empty' "$tracking_file" 2>/dev/null) || true - if [ "$tracked_sha" = "$head_sha" ]; then - log_err " [ci-check] HEAD SHA $head_sha already tracked for branch $branch_name. Skipping." - return 1 - fi - fi - fi - - # Get check statuses - local checks_json - checks_json=$(gh pr checks "$pr_number" --json name,state,bucket 2>/dev/null) || true - if [ -z "$checks_json" ] || [ "$checks_json" = "[]" ] || [ "$checks_json" = "null" ]; then - log_err " [ci-check] No checks found for PR #$pr_number" - return 1 - fi - - # Loop prevention layer 3: skip if any checks are still pending - local pending_count - pending_count=$(echo "$checks_json" | jq '[.[] | select(.bucket == "pending" or .state == "PENDING" or .state == "QUEUED" or .state == "IN_PROGRESS")] | length' 2>/dev/null) || pending_count=0 - if [ "$pending_count" -gt 0 ]; then - log_err " [ci-check] $pending_count check(s) still pending for PR #$pr_number. Skipping." - return 1 - fi - - # Count failures - local fail_count - fail_count=$(echo "$checks_json" | jq '[.[] | select(.bucket == "fail" or .state == "FAILURE" or .state == "ERROR")] | length' 2>/dev/null) || fail_count=0 - log_err " [ci-check] Failed checks: $fail_count" - - if [ "$fail_count" -gt 0 ]; then - echo "$pr_number" - return 0 - fi - - log_err " [ci-check] No CI failures for PR #$pr_number." - return 1 -} - -# Collect CI failure details for a PR (plain text output for Claude prompt) -get_ci_failure_details() { - local work_dir="$1" - local pr_number="$2" - - cd "$work_dir" - - log_err " [ci-details] Collecting CI failure details for PR #$pr_number..." - - local branch_name - branch_name=$(gh pr view "$pr_number" --json headRefName --jq '.headRefName' 2>/dev/null) || true - - # Get failed check names - local failed_checks - failed_checks=$(gh pr checks "$pr_number" --json name,state,bucket --jq '[.[] | select(.bucket == "fail" or .state == "FAILURE" or .state == "ERROR")] | .[].name' 2>/dev/null) || true - - echo "## Failed Checks" - echo "$failed_checks" - echo "" - - # Find failed GitHub Actions runs on this branch for the HEAD commit - local head_sha - head_sha=$(git rev-parse "origin/$branch_name" 2>/dev/null) || true - - if [ -z "$head_sha" ]; then - log_err " [ci-details] Could not determine HEAD SHA for branch $branch_name" - echo "## Failure Logs" - echo "(Could not determine HEAD SHA to fetch logs)" - return 0 - fi - - local failed_runs - failed_runs=$(gh run list --branch "$branch_name" --status failure --json databaseId,headSha,name --jq "[.[] | select(.headSha == \"$head_sha\")] | .[].databaseId" 2>/dev/null) || true - - if [ -z "$failed_runs" ]; then - log_err " [ci-details] No failed runs found for HEAD commit $head_sha" - echo "## Failure Logs" - echo "(No failed GitHub Actions runs found for HEAD commit)" - return 0 - fi - - echo "## Failure Logs" - echo "" - - for run_id in $failed_runs; do - local run_name - run_name=$(gh run view "$run_id" --json name --jq '.name' 2>/dev/null) || run_name="Run $run_id" - echo "### $run_name (run $run_id)" - echo '```' - gh run view "$run_id" --log-failed 2>/dev/null | tail -200 - echo '```' - echo "" - done -} - -# Fetch all non-resolved, non-outdated PR comments for review context -get_pr_review_comments() { - local work_dir="$1" - local pr_number="$2" - local tmpdir - tmpdir=$(mktemp -d) - - cd "$work_dir" - - log_err " [comments] Fetching review comments for PR #$pr_number..." - - # Get review comments via GraphQL - only unresolved, non-outdated threads - local repo_nwo - repo_nwo=$(gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null) || repo_nwo="" - local repo_owner="${repo_nwo%%/*}" - local repo_name="${repo_nwo##*/}" - - if [ -n "$repo_owner" ] && [ -n "$repo_name" ]; then - gh api graphql -f query=' - query($owner: String!, $name: String!, $pr: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - nodes { - isResolved - isOutdated - comments(first: 50) { - nodes { - databaseId - author { login } - path - line - originalLine - body - createdAt - } - } - } - } - } - } - } - ' -f owner="$repo_owner" -f name="$repo_name" -F pr="$pr_number" 2>/dev/null | jq ' - [(.data.repository.pullRequest.reviewThreads.nodes // [])[] - | select(.isResolved == false and .isOutdated == false) - | (.comments.nodes // [])[] - | select(.body | startswith("🤖 **eternity-loop bot:**") | not) - | { - id: .databaseId, - author: .author.login, - path: .path, - line: (.line // .originalLine), - body: .body, - created_at: .createdAt - } - ] - ' > "$tmpdir/review_comments.json" 2>/dev/null || echo "[]" > "$tmpdir/review_comments.json" - else - log_err " [comments] WARNING: Could not determine repo owner/name, skipping inline review comments" - echo "[]" > "$tmpdir/review_comments.json" - fi - - local rc_count - rc_count=$(jq 'length' "$tmpdir/review_comments.json" 2>/dev/null || echo 0) - log_err " [comments] Inline review comments (unresolved, non-outdated): $rc_count" - - # Get issue comments (top-level PR comments) - exclude bot replies - gh api "repos/{owner}/{repo}/issues/$pr_number/comments" --jq ' - [.[] | select(.body | startswith("🤖 **eternity-loop bot:**") | not) | { - id: .id, - author: .user.login, - body: .body, - created_at: .created_at - }] - ' > "$tmpdir/issue_comments.json" 2>/dev/null || echo "[]" > "$tmpdir/issue_comments.json" - - local ic_count - ic_count=$(jq 'length' "$tmpdir/issue_comments.json" 2>/dev/null || echo 0) - log_err " [comments] Top-level PR comments (excluding bot): $ic_count" - - # Get PR reviews with body text - gh api "repos/{owner}/{repo}/pulls/$pr_number/reviews" --jq ' - [.[] | select(.body != null and .body != "") | { - id: .id, - author: .user.login, - state: .state, - body: .body, - created_at: .submitted_at - }] - ' > "$tmpdir/reviews.json" 2>/dev/null || echo "[]" > "$tmpdir/reviews.json" - - local rv_count - rv_count=$(jq 'length' "$tmpdir/reviews.json" 2>/dev/null || echo 0) - log_err " [comments] Review bodies with text: $rv_count" - log_err " [comments] Total comments collected: $((rc_count + ic_count + rv_count))" - - # Combine into a single JSON object - python3 -c " -import json -rc = json.load(open('$tmpdir/review_comments.json')) -ic = json.load(open('$tmpdir/issue_comments.json')) -rv = json.load(open('$tmpdir/reviews.json')) -print(json.dumps({ - 'review_comments': rc, - 'issue_comments': ic, - 'reviews': rv -}, indent=2)) -" - - rm -rf "$tmpdir" -} - -# Find "In Review" Linear issues that have new PR reviews -# Returns issue JSON on success, 1 on failure -check_review_tasks() { - local team_id="$1" - local work_dir="$2" - local project_id="${3:-}" - - log_err "[review] Checking for 'In Review'/'In Progress' issues with new PR feedback..." - - # Fetch all prd-labeled issues via GraphQL - local filter - filter=$(build_issue_filter "$team_id" "$project_id" '{"labels": {"some": {"name": {"eq": "prd"}}}}') - - local query='query($filter: IssueFilter!) { - issues(filter: $filter, first: 50) { - nodes { id identifier title description url branchName state { name } } - } - }' - local variables - variables=$(jq -n --argjson f "$filter" '{filter: $f}') - log_err "[review] Querying Linear API..." - local result - result=$(linear_graphql "$query" "$variables") - log_err "[review] Linear API response: $(echo "$result" | head -c 500)" - - # Filter to issues with status containing "review" or "progress" - local issues - issues=$(echo "$result" | jq '[.data.issues.nodes[] | select(.state.name | test("review|progress"; "i"))]') - - if [ -z "$issues" ] || [ "$issues" = "[]" ] || [ "$issues" = "null" ]; then - log_err "[review] No 'In Review'/'In Progress' issues found in Linear." - return 1 - fi - - # Check each issue for new human PR reviews - local count - count=$(echo "$issues" | jq 'length') - log_err "[review] Found $count review-candidate issue(s). Checking for new PR reviews..." - - for idx in $(seq 0 $((count - 1))); do - local issue_json - issue_json=$(echo "$issues" | jq -c ".[$idx]") - local issue_id - issue_id=$(echo "$issue_json" | jq -r '.identifier // .id') - local issue_title - issue_title=$(echo "$issue_json" | jq -r '.title // ""') - - log_err "[review] Checking issue $((idx + 1))/$count: $issue_id - $issue_title" - - # Find the PR for this issue by searching for the issue identifier in PR titles/branches - cd "$work_dir" - log_err " [review] Searching for PR related to $issue_id..." - local pr_json - pr_json=$(gh pr list --state open --json number,headRefName,title --jq " - [.[] | select(.title | ascii_downcase | contains(\"$issue_id\" | ascii_downcase))] | .[0] // empty - " 2>/dev/null) || true - - # Fall back: search by Linear branch name if no PR found by title - if [ -z "$pr_json" ] || [ "$pr_json" = "null" ]; then - local linear_branch - linear_branch=$(echo "$issue_json" | jq -r '.branchName // empty') - if [ -n "$linear_branch" ]; then - log_err " [review] No PR found by title. Trying Linear branch: $linear_branch" - pr_json=$(gh pr list --head "$linear_branch" --state open --json number,headRefName,title --jq '.[0] // empty' 2>/dev/null) || true - fi - fi - - if [ -z "$pr_json" ] || [ "$pr_json" = "null" ]; then - log_err " [review] No open PR found for $issue_id. Skipping." - continue - fi - - local pr_number - pr_number=$(echo "$pr_json" | jq -r '.number') - local branch_name - branch_name=$(echo "$pr_json" | jq -r '.headRefName') - local pr_title - pr_title=$(echo "$pr_json" | jq -r '.title') - log_err " [review] Found PR #$pr_number: $pr_title (branch: $branch_name)" - - # Fetch remote to ensure we have latest - log_err " [review] Fetching origin/$branch_name..." - if ! git fetch origin "$branch_name" 2>/dev/null; then - log_err " [review] Branch $branch_name not found on remote. Skipping." - continue - fi - - # Check for new human comments using the PR's actual branch - local verified_pr - verified_pr=$(check_pr_has_new_human_comments "$work_dir" "$branch_name") || { - log_err " [review] No new human comments for $issue_id. Skipping." - continue - } - - log_err "[review] >>> Found new comments on PR #$pr_number for $issue_id! Processing..." - - # Return the issue JSON augmented with pr_number and the actual PR branch - echo "$issue_json" | jq -c ". + {\"prNumber\": $pr_number, \"branchName\": \"$branch_name\"}" - return 0 - done - - log_err "[review] No review-candidate issues have new human PR reviews." - return 1 -} - -# Generate a review-addressing prd.json from PR comments -start_review_task() { - local work_dir="$1" - local issue_json="$2" - local ralph_dir="$work_dir/scripts/ralph" - - local issue_id issue_title issue_url branch_name - parse_issue_fields "$issue_json" - local pr_number - pr_number=$(printf '%s' "$issue_json" | jq -r '.prNumber') - - log_err "[start-review] Generating review-addressing PRD for $issue_id (PR #$pr_number)..." - log_err "[start-review] Issue: $issue_title" - log_err "[start-review] Branch: $branch_name" - log_err "[start-review] URL: $issue_url" - - mkdir -p "$ralph_dir" - - # Collect all non-outdated PR comments - log_err "[start-review] Collecting PR comments..." - local comments - comments=$(get_pr_review_comments "$work_dir" "$pr_number") - log_err "[start-review] PR comments collected." - - # Clean working tree and check out the branch - clean_working_tree "$work_dir" - log_err "[start-review] Checking out branch $branch_name..." - if ! git checkout "$branch_name" 2>/dev/null; then - log_err "[start-review] Branch $branch_name not found locally, creating from origin..." - git checkout -b "$branch_name" "origin/$branch_name" || { - log_err "[start-review] ERROR: Could not check out branch $branch_name" - return 1 - } - fi - git pull origin "$branch_name" --ff-only 2>/dev/null || true - log_err "[start-review] On branch: $(git branch --show-current), latest commit: $(git log -1 --format='%h %s')" - log_err "[start-review] Invoking Claude to generate review prd.json..." - - claude --dangerously-skip-permissions --print -p "$(read_prompt create-review-prd.md) - -Save to: $ralph_dir/prd.json -branchName: $branch_name -project: \"$issue_id: $issue_title (review feedback)\" - -## Linear Issue (for context only) -- ID: $issue_id -- Title: $issue_title -- URL: $issue_url - -## PR #$pr_number Review Comments - -$comments - -$RALPH_SKILL_GUIDELINES" 2>&1 | tee /dev/stderr || true - - verify_prd "$ralph_dir/prd.json" "start-review" || return 1 - return 0 -} - -# --- CI Failure Handling: Check PRs for CI failures and attempt fixes --- - -# Find "In Review"/"In Progress" Linear issues with CI failures on their PRs -# Returns issue JSON on success, 1 on failure -check_ci_tasks() { - local team_id="$1" - local work_dir="$2" - local project_id="${3:-}" - - log_err "[ci] Checking for issues with CI failures on PRs..." - - # Same query as review tasks: prd-labeled issues in review/progress states - local filter - filter=$(build_issue_filter "$team_id" "$project_id" '{"labels": {"some": {"name": {"eq": "prd"}}}}') - - local query='query($filter: IssueFilter!) { - issues(filter: $filter, first: 50) { - nodes { id identifier title description url branchName state { name } } - } - }' - local variables - variables=$(jq -n --argjson f "$filter" '{filter: $f}') - log_err "[ci] Querying Linear API..." - local result - result=$(linear_graphql "$query" "$variables") - - # Filter to issues with status containing "review" or "progress" - local issues - issues=$(echo "$result" | jq '[.data.issues.nodes[] | select(.state.name | test("review|progress"; "i"))]') - - if [ -z "$issues" ] || [ "$issues" = "[]" ] || [ "$issues" = "null" ]; then - log_err "[ci] No 'In Review'/'In Progress' issues found." - return 1 - fi - - local count - count=$(echo "$issues" | jq 'length') - log_err "[ci] Found $count candidate issue(s). Checking for CI failures..." - - for idx in $(seq 0 $((count - 1))); do - local issue_json - issue_json=$(echo "$issues" | jq -c ".[$idx]") - local issue_id - issue_id=$(echo "$issue_json" | jq -r '.identifier // .id') - local issue_title - issue_title=$(echo "$issue_json" | jq -r '.title // ""') - - log_err "[ci] Checking issue $((idx + 1))/$count: $issue_id - $issue_title" - - # Find the PR for this issue by searching for the issue identifier in PR titles - cd "$work_dir" - log_err " [ci] Searching for PR related to $issue_id..." - local pr_json - pr_json=$(gh pr list --state open --json number,headRefName,title --jq " - [.[] | select(.title | ascii_downcase | contains(\"$issue_id\" | ascii_downcase))] | .[0] // empty - " 2>/dev/null) || true - - # Fall back: search by Linear branch name - if [ -z "$pr_json" ] || [ "$pr_json" = "null" ]; then - local linear_branch - linear_branch=$(echo "$issue_json" | jq -r '.branchName // empty') - if [ -n "$linear_branch" ]; then - log_err " [ci] No PR found by title. Trying Linear branch: $linear_branch" - pr_json=$(gh pr list --head "$linear_branch" --state open --json number,headRefName,title --jq '.[0] // empty' 2>/dev/null) || true - fi - fi - - if [ -z "$pr_json" ] || [ "$pr_json" = "null" ]; then - log_err " [ci] No open PR found for $issue_id. Skipping." - continue - fi - - local pr_number - pr_number=$(echo "$pr_json" | jq -r '.number') - local branch_name - branch_name=$(echo "$pr_json" | jq -r '.headRefName') - log_err " [ci] Found PR #$pr_number (branch: $branch_name)" - - # Fetch remote to ensure we have latest - log_err " [ci] Fetching origin/$branch_name..." - if ! git fetch origin "$branch_name" 2>/dev/null; then - log_err " [ci] Branch $branch_name not found on remote. Skipping." - continue - fi - - # Check for CI failures - local verified_pr - verified_pr=$(check_pr_has_ci_failures "$work_dir" "$branch_name") || { - log_err " [ci] No actionable CI failures for $issue_id. Skipping." - continue - } - - log_err "[ci] >>> Found CI failures on PR #$pr_number for $issue_id! Processing..." - - echo "$issue_json" | jq -c ". + {\"prNumber\": $pr_number, \"branchName\": \"$branch_name\"}" - return 0 - done - - log_err "[ci] No issues have actionable CI failures." - return 1 -} - -# Generate a CI-fix prd.json from failure logs -start_ci_fix_task() { - local work_dir="$1" - local issue_json="$2" - local ralph_dir="$work_dir/scripts/ralph" - - local issue_id issue_title issue_url branch_name - parse_issue_fields "$issue_json" - local pr_number - pr_number=$(printf '%s' "$issue_json" | jq -r '.prNumber') - - log_err "[start-ci-fix] Generating CI-fix PRD for $issue_id (PR #$pr_number)..." - log_err "[start-ci-fix] Branch: $branch_name" - - mkdir -p "$ralph_dir" - - # Collect CI failure details - log_err "[start-ci-fix] Collecting CI failure details..." - local failure_details - failure_details=$(get_ci_failure_details "$work_dir" "$pr_number") - log_err "[start-ci-fix] CI failure details collected." - - # Clean working tree and check out the branch - clean_working_tree "$work_dir" - log_err "[start-ci-fix] Checking out branch $branch_name..." - if ! git checkout "$branch_name" 2>/dev/null; then - log_err "[start-ci-fix] Branch $branch_name not found locally, creating from origin..." - git checkout -b "$branch_name" "origin/$branch_name" || { - log_err "[start-ci-fix] ERROR: Could not check out branch $branch_name" - return 1 - } - fi - git pull origin "$branch_name" --ff-only 2>/dev/null || true - log_err "[start-ci-fix] On branch: $(git branch --show-current), latest commit: $(git log -1 --format='%h %s')" - - # Record fix attempt in tracking file BEFORE generating PRD - local tracking_file="$SETTINGS_DIR/ci-fix-tracking.json" - local head_sha - head_sha=$(git rev-parse HEAD 2>/dev/null) || true - if [ -n "$head_sha" ]; then - mkdir -p "$SETTINGS_DIR" - if [ -f "$tracking_file" ]; then - local updated - updated=$(jq --arg b "$branch_name" --arg s "$head_sha" '.[$b] = $s' "$tracking_file" 2>/dev/null) || updated="{\"$branch_name\": \"$head_sha\"}" - echo "$updated" > "$tracking_file" - else - echo "{\"$branch_name\": \"$head_sha\"}" > "$tracking_file" - fi - log_err "[start-ci-fix] Recorded fix attempt: $branch_name -> $head_sha" - fi - - log_err "[start-ci-fix] Invoking Claude to generate CI-fix prd.json..." - - claude --dangerously-skip-permissions --print -p "$(read_prompt create-ci-fix-prd.md) - -Save to: $ralph_dir/prd.json -branchName: $branch_name -project: \"$issue_id: $issue_title (CI fix)\" - -## Linear Issue (for context only) -- ID: $issue_id -- Title: $issue_title -- URL: $issue_url - -## CI Failure Details - -$failure_details - -$RALPH_SKILL_GUIDELINES" 2>&1 | tee /dev/stderr || true - - verify_prd "$ralph_dir/prd.json" "start-ci-fix" || return 1 - return 0 -} - -# --- Main Loop --- - -# Suppress notifications in child processes -export DISABLE_PUSHOVER_NOTIFICATIONS=true -export RALPH_LOOP=true - -log "=============================================" -log " Ralph Linear Loop" -log " Tool: $TOOL | Max iterations: $MAX_ITERATIONS" -log "=============================================" - -ensure_settings - -TEAM_ID=$(get_setting "teamId") -PROJECT_ID=$(get_setting "projectId") -WORK_DIR=$(get_setting "workingDirectory") - -if [ -z "$TEAM_ID" ] || [ -z "$WORK_DIR" ]; then - log "ERROR: Missing teamId or workingDirectory in $SETTINGS_FILE" - exit 1 -fi - -log "Team: $TEAM_ID" -[ -n "$PROJECT_ID" ] && log "Project: $PROJECT_ID" -log "Working directory: $WORK_DIR" -echo "" - -while true; do - LOOP_START=$(date +%s) - log "" - log "-----------------------------------------------" - log " Loop iteration started" - log "-----------------------------------------------" - - TASK_TYPE="" - ISSUE_JSON="" - ISSUE_ID="" - BRANCH_NAME="" - - # --- Priority 1: Check for "In Review" issues with new PR reviews --- - log "[loop] Priority 1: Checking for issues with new PR reviews..." - REVIEW_JSON="" - set +e - REVIEW_JSON=$(check_review_tasks "$TEAM_ID" "$WORK_DIR" "$PROJECT_ID") - REVIEW_EXIT=$? - set -e - log "[loop] check_review_tasks exited with status $REVIEW_EXIT" - - if [ -n "$REVIEW_JSON" ]; then - TASK_TYPE="review" - ISSUE_JSON="$REVIEW_JSON" - log "[loop] Review JSON received ($(printf '%s' "$ISSUE_JSON" | wc -c | tr -d ' ') bytes)" - set +e - parse_issue_fields "$ISSUE_JSON" - PARSE_EXIT=$? - set -e - if [ "$PARSE_EXIT" -ne 0 ]; then - log "[loop] ERROR: parse_issue_fields failed with status $PARSE_EXIT. Skipping..." - sleep "$POLL_INTERVAL" - continue - fi - ISSUE_ID="$issue_id" - BRANCH_NAME="$branch_name" - PR_NUMBER=$(echo "$ISSUE_JSON" | jq -r '.prNumber') - log "[loop] Parsed review task: issue=$ISSUE_ID branch=$BRANCH_NAME pr=#$PR_NUMBER" - - log "" - log "=============================================" - log " Addressing PR review: $ISSUE_ID (PR #$PR_NUMBER)" - log " Branch: $BRANCH_NAME" - log "=============================================" - - # Checkout the existing branch and generate review prd.json - cd "$WORK_DIR" - log "[loop] Starting review task..." - start_review_task "$WORK_DIR" "$ISSUE_JSON" || { - log "[loop] Failed to generate review PRD. Skipping..." - sleep "$POLL_INTERVAL" - continue - } - log "[loop] Review task PRD generated successfully." - fi - - # --- Priority 2: Check for CI failures on existing PRs --- - if [ -z "$TASK_TYPE" ]; then - log "[loop] Priority 2: Checking for CI failures on PRs..." - CI_JSON="" - set +e - CI_JSON=$(check_ci_tasks "$TEAM_ID" "$WORK_DIR" "$PROJECT_ID") - CI_EXIT=$? - set -e - log "[loop] check_ci_tasks exited with status $CI_EXIT" - - if [ -n "$CI_JSON" ]; then - TASK_TYPE="ci-fix" - ISSUE_JSON="$CI_JSON" - log "[loop] CI fix JSON received ($(printf '%s' "$ISSUE_JSON" | wc -c | tr -d ' ') bytes)" - set +e - parse_issue_fields "$ISSUE_JSON" - PARSE_EXIT=$? - set -e - if [ "$PARSE_EXIT" -ne 0 ]; then - log "[loop] ERROR: parse_issue_fields failed with status $PARSE_EXIT. Skipping..." - sleep "$POLL_INTERVAL" - continue - fi - ISSUE_ID="$issue_id" - BRANCH_NAME="$branch_name" - PR_NUMBER=$(echo "$ISSUE_JSON" | jq -r '.prNumber') - log "[loop] Parsed CI fix task: issue=$ISSUE_ID branch=$BRANCH_NAME pr=#$PR_NUMBER" - - log "" - log "=============================================" - log " Fixing CI failures: $ISSUE_ID (PR #$PR_NUMBER)" - log " Branch: $BRANCH_NAME" - log "=============================================" - - cd "$WORK_DIR" - log "[loop] Starting CI fix task..." - start_ci_fix_task "$WORK_DIR" "$ISSUE_JSON" || { - log "[loop] Failed to generate CI fix PRD. Skipping..." - sleep "$POLL_INTERVAL" - continue - } - log "[loop] CI fix task PRD generated successfully." - fi - fi - - # --- Priority 3: Check for new "Todo" issues --- - if [ -z "$TASK_TYPE" ]; then - log "[loop] No review or CI fix tasks found. Priority 3: Checking for 'Todo' issues..." - - ISSUE_JSON=$(start_task "$TEAM_ID" "$WORK_DIR" "$PROJECT_ID") || true - - if [ -z "$ISSUE_JSON" ]; then - log "[loop] No tasks found (review, ci-fix, or todo). Polling again in ${POLL_INTERVAL}s..." - sleep "$POLL_INTERVAL" - continue - fi - - # Start from clean main branch for new tasks - ensure_main_branch "$WORK_DIR" - - TASK_TYPE="new" - parse_issue_fields "$ISSUE_JSON" - ISSUE_ID="$issue_id" - BRANCH_NAME="$branch_name" - - log "" - log "=============================================" - log " Starting task: $ISSUE_ID" - log " Branch: $BRANCH_NAME" - log "=============================================" - - # Create/checkout the feature branch - cd "$WORK_DIR" - log "[loop] Creating/checking out branch: $BRANCH_NAME" - git checkout -b "$BRANCH_NAME" 2>/dev/null || git checkout "$BRANCH_NAME" - log "[loop] On branch: $(git branch --show-current)" - fi - - # Write project-specific CLAUDE.md that tells Ralph NOT to manage branches - # (branch management is handled by this script) - ralph_dir="$WORK_DIR/scripts/ralph" - mkdir -p "$ralph_dir" - # Copy ralph CLAUDE.md with branch override (step 3 changed from upstream) - cp "$PROMPTS_DIR/ralph-claude-md.md" "$ralph_dir/CLAUDE.md" - log "[loop] Wrote scripts/ralph/CLAUDE.md (branch override)" - - # Run ralph-loop.sh from the project directory - log "" - log "[loop] Starting ralph-loop.sh (tool: $TOOL, max iterations: $MAX_ITERATIONS, task type: $TASK_TYPE)..." - log "[loop] Working directory: $WORK_DIR" - log "[loop] Current branch: $(cd "$WORK_DIR" && git branch --show-current)" - RALPH_EXIT=0 - CLAUDE_PROJECT_DIR="$WORK_DIR" "$SCRIPT_DIR/ralph-loop.sh" --tool "$TOOL" "$MAX_ITERATIONS" || RALPH_EXIT=$? - log "[loop] ralph-loop.sh exited with status: $RALPH_EXIT" - - # Finalize based on task type and exit status - if [ "$TASK_TYPE" = "review" ]; then - # For review tasks: push updates to the existing PR - cd "$WORK_DIR" - log "[loop] Pushing review fixes to origin..." - git push origin "$(git branch --show-current)" 2>/dev/null || true - log "[loop] Pushed review fixes to PR #$PR_NUMBER." - - # Reply to each PR comment explaining how it was addressed - log "[loop] Replying to PR comments on PR #$PR_NUMBER..." - review_comments=$(get_pr_review_comments "$WORK_DIR" "$PR_NUMBER") - claude --dangerously-skip-permissions --print -p "$(sed "s/PR_NUMBER/$PR_NUMBER/g" "$PROMPTS_DIR/reply-to-pr-comments.md") - -## PR #$PR_NUMBER Review Comments -$review_comments - -## Recent commits addressing this feedback -$(cd "$WORK_DIR" && git log --oneline -20)" 2>&1 | tee /dev/stderr || true - log "[loop] Finished replying to PR comments." - elif [ "$TASK_TYPE" = "ci-fix" ]; then - # For CI fix tasks: push updates to the existing PR (no comment replies needed) - cd "$WORK_DIR" - log "[loop] Pushing CI fixes to origin..." - git push origin "$(git branch --show-current)" 2>/dev/null || true - log "[loop] Pushed CI fixes to PR #$PR_NUMBER." - else - # For new tasks: create PR based on exit status - if [ "$RALPH_EXIT" -eq 0 ]; then - log "[loop] Ralph completed successfully. Creating PR..." - finalize_task "$WORK_DIR" "$ISSUE_JSON" "false" - else - log "[loop] Ralph exited with status $RALPH_EXIT. Creating draft PR..." - finalize_task "$WORK_DIR" "$ISSUE_JSON" "true" - fi - fi - - LOOP_END=$(date +%s) - LOOP_DURATION=$(( LOOP_END - LOOP_START )) - log "" - log "[loop] Task $ISSUE_ID ($TASK_TYPE) complete in ${LOOP_DURATION}s. Moving to next task..." - log "" -done diff --git a/scripts/eternity-loop/ai-runner.ts b/scripts/eternity-loop/ai-runner.ts new file mode 100644 index 0000000..71dedf1 --- /dev/null +++ b/scripts/eternity-loop/ai-runner.ts @@ -0,0 +1,21 @@ +import { $ } from "bun"; +import { log, startSpinner } from "./logger"; + +export interface AiRunner { + run(prompt: string, workDir: string): Promise; +} + +export class ClaudeCliRunner implements AiRunner { + async run(prompt: string, workDir: string): Promise { + log("[claude] Running Claude, this could take a while..."); + const spinner = startSpinner("Claude is thinking..."); + try { + const result = await $`claude --dangerously-skip-permissions --print -p ${prompt}` + .cwd(workDir) + .text(); + return result; + } finally { + spinner.stop(); + } + } +} diff --git a/scripts/eternity-loop/bootstrap.ts b/scripts/eternity-loop/bootstrap.ts new file mode 100644 index 0000000..b3d7741 --- /dev/null +++ b/scripts/eternity-loop/bootstrap.ts @@ -0,0 +1,81 @@ +import { $ } from "bun"; +import { dirname, join } from "node:path"; +import { startSpinner } from "./logger"; + +export function isInsideSession(): boolean { + return process.env.ETERNITY_LOOP_INSIDE === "1"; +} + +export async function cleanupWorktree(): Promise { + const worktreePath = process.env.ETERNITY_LOOP_WORKTREE; + const repoRoot = process.env.ETERNITY_LOOP_REPO_ROOT; + if (!worktreePath || !repoRoot) return; + + try { + await $`git -C ${repoRoot} worktree remove --force ${worktreePath}`.quiet(); + } catch { + // Worktree may already be gone + } +} + +export async function bootstrap(args: string[]): Promise { + const repoRoot = (await $`git rev-parse --show-toplevel`.text()).trim(); + const projectName = repoRoot.split("/").pop() ?? "unknown"; + const tmuxSession = `eternity-loop-${projectName}`; + const worktreePath = join(repoRoot, ".claude/worktrees/eternity-loop"); + const scriptPath = join(dirname(import.meta.path), "index.ts"); + + const spinner = startSpinner("Starting up..."); + + // Kill existing tmux session if running + try { + await $`tmux has-session -t ${tmuxSession}`.quiet(); + await $`tmux kill-session -t ${tmuxSession}`.quiet(); + } catch { + // No existing session + } + + // Remove existing worktree + try { + await $`git worktree remove --force ${worktreePath}`.quiet(); + } catch { + try { + await $`rm -rf ${worktreePath}`.quiet(); + } catch { + // Already gone + } + } + + // Create fresh worktree detached on origin/main + await $`mkdir -p ${dirname(worktreePath)}`; + await $`git fetch origin`.quiet(); + + let mainRef = "origin/main"; + try { + await $`git rev-parse --verify origin/main`.quiet(); + } catch { + mainRef = "origin/master"; + } + + await $`git worktree add ${worktreePath} --detach ${mainRef}`; + + spinner.stop(); + + // Build the command to run inside tmux + const argsStr = args.join(" "); + const envVars = [ + `ETERNITY_LOOP_INSIDE=1`, + `ETERNITY_LOOP_WORKTREE='${worktreePath}'`, + `ETERNITY_LOOP_REPO_ROOT='${repoRoot}'`, + `LINEAR_API_KEY='${process.env.LINEAR_API_KEY ?? ""}'`, + ].join(" "); + + const cmd = `cd '${worktreePath}' && ${envVars} bun '${scriptPath}' ${argsStr}; echo 'Eternity loop exited. Press enter to close.'; read`; + + // Launch new tmux session + const proc = Bun.spawn(["tmux", "new-session", "-s", tmuxSession, cmd], { + stdio: ["inherit", "inherit", "inherit"], + }); + + await proc.exited; +} diff --git a/scripts/eternity-loop/config.ts b/scripts/eternity-loop/config.ts new file mode 100644 index 0000000..855ab8c --- /dev/null +++ b/scripts/eternity-loop/config.ts @@ -0,0 +1,21 @@ +import { mkdir } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import type { Settings } from "./types"; + +function settingsPath(repoRoot: string): string { + return join(repoRoot, ".eternity-loop", "settings.json"); +} + +export async function loadSettings(repoRoot: string): Promise { + const file = Bun.file(settingsPath(repoRoot)); + if (!(await file.exists())) { + return null; + } + return file.json() as Promise; +} + +export async function saveSettings(repoRoot: string, settings: Settings): Promise { + const path = settingsPath(repoRoot); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, JSON.stringify(settings, null, 2) + "\n"); +} diff --git a/scripts/eternity-loop-prompts/create-ci-fix-prd.md b/scripts/eternity-loop/eternity-loop-prompts/create-ci-fix-prd.md similarity index 75% rename from scripts/eternity-loop-prompts/create-ci-fix-prd.md rename to scripts/eternity-loop/eternity-loop-prompts/create-ci-fix-prd.md index d92a365..8202478 100644 --- a/scripts/eternity-loop-prompts/create-ci-fix-prd.md +++ b/scripts/eternity-loop/eternity-loop-prompts/create-ci-fix-prd.md @@ -7,9 +7,10 @@ Use the provided branch name for the `branchName` field and project name for the Focus on the CI failure logs, not the original Linear issue (provided for context only). ## CI-fix-specific rules +- Investigate each CI failure until you've found a potential root cause - Each distinct CI failure becomes a user story - Group failures that share the same root cause into a single user story - Keep fixes minimal — do NOT refactor unrelated code or make improvements beyond what's needed to fix the failures -- Every user story MUST include "CI checks pass" as an acceptance criterion +- Every user story MUST include an acceptance criterion that the step that failed in CI passes locally (look into the workflow to determine what has to run locally) - Commit messages should use `fix(ci):` prefix - If the failure logs are unclear, include a user story for investigating and diagnosing the failure diff --git a/scripts/eternity-loop-prompts/create-pr.md b/scripts/eternity-loop/eternity-loop-prompts/create-pr.md similarity index 100% rename from scripts/eternity-loop-prompts/create-pr.md rename to scripts/eternity-loop/eternity-loop-prompts/create-pr.md diff --git a/scripts/eternity-loop-prompts/create-prd.md b/scripts/eternity-loop/eternity-loop-prompts/create-prd.md similarity index 100% rename from scripts/eternity-loop-prompts/create-prd.md rename to scripts/eternity-loop/eternity-loop-prompts/create-prd.md diff --git a/scripts/eternity-loop-prompts/create-review-prd.md b/scripts/eternity-loop/eternity-loop-prompts/create-review-prd.md similarity index 100% rename from scripts/eternity-loop-prompts/create-review-prd.md rename to scripts/eternity-loop/eternity-loop-prompts/create-review-prd.md diff --git a/scripts/eternity-loop-prompts/ralph-claude-md.md b/scripts/eternity-loop/eternity-loop-prompts/ralph-claude-md.md similarity index 100% rename from scripts/eternity-loop-prompts/ralph-claude-md.md rename to scripts/eternity-loop/eternity-loop-prompts/ralph-claude-md.md diff --git a/scripts/eternity-loop-prompts/reply-to-pr-comments.md b/scripts/eternity-loop/eternity-loop-prompts/reply-to-pr-comments.md similarity index 100% rename from scripts/eternity-loop-prompts/reply-to-pr-comments.md rename to scripts/eternity-loop/eternity-loop-prompts/reply-to-pr-comments.md diff --git a/scripts/eternity-loop/git.ts b/scripts/eternity-loop/git.ts new file mode 100644 index 0000000..053eef1 --- /dev/null +++ b/scripts/eternity-loop/git.ts @@ -0,0 +1,69 @@ +import { $ } from "bun"; + +export async function cleanWorkingTree(workDir: string): Promise { + await $`git -C ${workDir} checkout -- .`; + await $`git -C ${workDir} clean -fd`; +} + +export async function ensureMainBranch(workDir: string): Promise { + await $`git -C ${workDir} fetch origin`; + + // Try origin/main first, fall back to origin/master + const mainRef = await $`git -C ${workDir} rev-parse --verify origin/main` + .quiet() + .then(() => "origin/main") + .catch(() => "origin/master"); + + await $`git -C ${workDir} checkout --detach ${mainRef}`; + await cleanWorkingTree(workDir); +} + +export async function checkoutBranch(workDir: string, branch: string): Promise { + await $`git -C ${workDir} fetch origin`.quiet(); + + // Try checking out existing branch first, create from remote if available + try { + await $`git -C ${workDir} checkout ${branch}`.quiet(); + } catch { + try { + await $`git -C ${workDir} checkout -b ${branch} origin/${branch}`.quiet(); + } catch { + await $`git -C ${workDir} checkout -b ${branch}`; + } + } + + // Pull latest from origin if the remote branch exists + try { + await $`git -C ${workDir} reset --hard origin/${branch}`.quiet(); + } catch { + // Remote branch may not exist yet for new branches + } +} + +export async function pushBranch(workDir: string, branch?: string): Promise { + const branchName = branch ?? await getCurrentBranch(workDir); + await $`git -C ${workDir} push -u origin ${branchName}`; +} + +export async function getCurrentBranch(workDir: string): Promise { + const result = await $`git -C ${workDir} branch --show-current`.text(); + return result.trim(); +} + +export async function getLatestCommitDate(workDir: string, branch?: string): Promise { + const ref = branch ? `origin/${branch}` : "HEAD"; + const result = await $`git -C ${workDir} log -1 --format=%ad --date=format-local:%Y-%m-%dT%H:%M:%SZ ${ref}`.text(); + return result.trim(); +} + +export async function getLatestCommitMessage(workDir: string, branch?: string): Promise { + const ref = branch ? `origin/${branch}` : "HEAD"; + const result = await $`git -C ${workDir} log -1 --format=%s ${ref}`.text(); + return result.trim(); +} + +export async function getHeadSha(workDir: string, ref?: string): Promise { + const target = ref ?? "HEAD"; + const result = await $`git -C ${workDir} rev-parse ${target}`.text(); + return result.trim(); +} diff --git a/scripts/eternity-loop/github/ci.ts b/scripts/eternity-loop/github/ci.ts new file mode 100644 index 0000000..cd94f40 --- /dev/null +++ b/scripts/eternity-loop/github/ci.ts @@ -0,0 +1,174 @@ +import type { Octokit } from "@octokit/rest"; +import { join } from "node:path"; +import { logDebug } from "../logger"; +import { countWorkflowRunsSinceLastHumanInteraction } from "./pr"; + +const MAX_WORKFLOW_ATTEMPTS = 3; + +interface CiFixTracking { + fixedShas: string[]; +} + +async function loadTracking(settingsDir: string): Promise { + const trackingPath = join(settingsDir, "ci-fix-tracking.json"); + const file = Bun.file(trackingPath); + if (await file.exists()) { + const data = await file.json(); + return { fixedShas: data.fixedShas ?? [] }; + } + return { fixedShas: [] }; +} + +export async function saveTracking(settingsDir: string, tracking: CiFixTracking): Promise { + const trackingPath = join(settingsDir, "ci-fix-tracking.json"); + await Bun.write(trackingPath, JSON.stringify(tracking, null, 2)); +} + +export async function recordFixedSha(settingsDir: string, headSha: string): Promise { + const tracking = await loadTracking(settingsDir); + tracking.fixedShas.push(headSha); + await saveTracking(settingsDir, tracking); +} + +export async function checkPrHasCiFailures( + octokit: Octokit, + owner: string, + repo: string, + branch: string, + settingsDir: string, + prNumber?: number, +): Promise { + // Layer 1: Skip if latest commit starts with 'fix(ci):' + const commitResult = await Bun.$`git log -1 --format=%s origin/${branch}`.quiet().text(); + if (commitResult.trim().startsWith("fix(ci):")) { + return false; + } + + // Layer 2: Skip if HEAD SHA already tracked + const sha = await Bun.$`git rev-parse origin/${branch}`.quiet().text(); + const headSha = sha.trim(); + const tracking = await loadTracking(settingsDir); + if (tracking.fixedShas.includes(headSha)) { + return false; + } + + // Layer 3: Check workflow attempt limit (max 3 since last human interaction) + if (prNumber) { + const runs = await countWorkflowRunsSinceLastHumanInteraction( + octokit, owner, repo, prNumber, "CI fix applied.", + ); + if (runs >= MAX_WORKFLOW_ATTEMPTS) { + logDebug(`[ci-fix] Skipping ${branch}: reached ${MAX_WORKFLOW_ATTEMPTS} CI fix attempts since last human interaction`); + return false; + } + } + + // Layer 4: Skip if any checks are pending + const { data: combinedStatus } = await octokit.repos.getCombinedStatusForRef({ + owner, + repo, + ref: branch, + }); + + const { data: checkRuns } = await octokit.checks.listForRef({ + owner, + repo, + ref: branch, + per_page: 100, + }); + + const hasPending = + combinedStatus.statuses.some((s) => s.state === "pending") || + checkRuns.check_runs.some((c) => c.status !== "completed"); + + if (hasPending) { + return false; + } + + // Check for actual failures + const hasFailedStatus = combinedStatus.statuses.some((s) => s.state === "failure" || s.state === "error"); + const hasFailedCheck = checkRuns.check_runs.some( + (c) => c.conclusion === "failure" || c.conclusion === "timed_out", + ); + + return hasFailedStatus || hasFailedCheck; +} + +export async function getCiFailureDetails( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, + branch: string, +): Promise { + const { data: checkRuns } = await octokit.checks.listForRef({ + owner, + repo, + ref: branch, + per_page: 100, + }); + + const failedChecks = checkRuns.check_runs.filter( + (c) => c.conclusion === "failure" || c.conclusion === "timed_out", + ); + + if (failedChecks.length === 0) { + return "No CI failures found."; + } + + const parts: string[] = [`## CI Failures for PR #${prNumber}\n`]; + + for (const check of failedChecks) { + parts.push(`### ${check.name} (${check.conclusion})`); + + // Try to get logs via actions API + if (check.details_url?.includes("/actions/runs/")) { + const runIdMatch = check.details_url.match(/\/runs\/(\d+)/); + if (runIdMatch) { + try { + const runId = parseInt(runIdMatch[1], 10); + const { data: jobs } = await octokit.actions.listJobsForWorkflowRun({ + owner, + repo, + run_id: runId, + }); + + const failedJob = jobs.jobs.find( + (j) => j.conclusion === "failure" && j.name === check.name, + ); + + if (failedJob) { + const { data: logData } = await octokit.actions.downloadJobLogsForWorkflowRun({ + owner, + repo, + job_id: failedJob.id, + }); + + const log = typeof logData === "string" ? logData : String(logData); + const lines = log.split("\n"); + + if (lines.length > 500) { + const first100 = lines.slice(0, 100).join("\n"); + const last400 = lines.slice(-400).join("\n"); + parts.push("```"); + parts.push(first100); + parts.push("\n... (truncated) ...\n"); + parts.push(last400); + parts.push("```"); + } else { + parts.push("```"); + parts.push(log); + parts.push("```"); + } + } + } catch { + parts.push("(Could not retrieve logs)"); + } + } + } + + parts.push(""); + } + + return parts.join("\n"); +} diff --git a/scripts/eternity-loop/github/client.ts b/scripts/eternity-loop/github/client.ts new file mode 100644 index 0000000..47fff5d --- /dev/null +++ b/scripts/eternity-loop/github/client.ts @@ -0,0 +1,25 @@ +import { Octokit } from "@octokit/rest"; + +export function createGitHubClient(): Octokit { + const token = process.env.GITHUB_TOKEN; + if (!token) { + throw new Error("GITHUB_TOKEN environment variable is not set"); + } + return new Octokit({ auth: token }); +} + +export async function getRepoInfo(workDir?: string): Promise<{ owner: string; repo: string }> { + const args = workDir ? ["-C", workDir] : []; + const result = await Bun.$`git ${args} remote get-url origin`.text(); + const url = result.trim(); + + // Handle both HTTPS and SSH URLs + // https://github.com/owner/repo.git + // git@github.com:owner/repo.git + const match = url.match(/github\.com[:/]([^/]+)\/([^/.]+)/); + if (!match) { + throw new Error(`Could not parse GitHub owner/repo from remote URL: ${url}`); + } + + return { owner: match[1], repo: match[2] }; +} diff --git a/scripts/eternity-loop/github/pr.ts b/scripts/eternity-loop/github/pr.ts new file mode 100644 index 0000000..3f63d3a --- /dev/null +++ b/scripts/eternity-loop/github/pr.ts @@ -0,0 +1,289 @@ +import type { Octokit } from "@octokit/rest"; +import { graphql } from "@octokit/graphql"; + +export async function findPrByBranch( + octokit: Octokit, + owner: string, + repo: string, + branch: string, +): Promise<{ number: number; title: string; url: string } | null> { + const { data: prs } = await octokit.pulls.list({ + owner, + repo, + head: `${owner}:${branch}`, + state: "open", + per_page: 1, + }); + if (prs.length === 0) return null; + return { number: prs[0].number, title: prs[0].title, url: prs[0].html_url }; +} + +export async function findPrByTitle( + octokit: Octokit, + owner: string, + repo: string, + title: string, +): Promise<{ number: number; title: string; url: string } | null> { + const { data: prs } = await octokit.pulls.list({ + owner, + repo, + state: "open", + per_page: 100, + }); + const match = prs.find((pr) => pr.title === title); + if (!match) return null; + return { number: match.number, title: match.title, url: match.html_url }; +} + +export async function createPr( + octokit: Octokit, + owner: string, + repo: string, + options: { title: string; body: string; head: string; base?: string; draft?: boolean }, +): Promise<{ number: number; url: string }> { + const { data: pr } = await octokit.pulls.create({ + owner, + repo, + title: options.title, + body: options.body, + head: options.head, + base: options.base ?? "main", + draft: options.draft ?? false, + }); + return { number: pr.number, url: pr.html_url }; +} + +export async function postPrComment( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, + body: string, +): Promise { + await octokit.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); +} + +export async function replyToReviewComment( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, + commentId: number, + body: string, +): Promise { + await octokit.pulls.createReplyForReviewComment({ + owner, + repo, + pull_number: prNumber, + comment_id: commentId, + body, + }); +} + +export interface PrComment { + id: number; + author: string; + body: string; + createdAt: string; + type: "review_thread" | "issue_comment" | "review_body"; + isBot: boolean; +} + +const BOT_COMMENT_PREFIX = "🤖 **eternity-loop bot:**"; +const BOT_USERS = ["copilot", "github-actions[bot]"]; + +function checkIsBot(author: string, body: string): boolean { + return BOT_USERS.includes(author) || body.startsWith(BOT_COMMENT_PREFIX); +} + +interface ReviewThreadsResponse { + repository: { + pullRequest: { + reviewThreads: { + nodes: Array<{ + isResolved: boolean; + isOutdated: boolean; + comments: { + nodes: Array<{ + id: string; + databaseId: number; + author: { login: string } | null; + body: string; + createdAt: string; + }>; + }; + }>; + }; + }; + }; +} + +/** + * Fetch all PR comments (human and bot) from review threads, issue comments, + * and review bodies. Each comment is tagged with `isBot`. + */ +export async function fetchAllPrComments( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, +): Promise { + const token = process.env.GITHUB_TOKEN; + const gql = graphql.defaults({ headers: { authorization: `token ${token}` } }); + + // 1. Review threads via GraphQL (unresolved, non-outdated only) + const threadData = await gql( + `query($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + reviewThreads(first: 100) { + nodes { + isResolved + isOutdated + comments(first: 100) { + nodes { + id + databaseId + author { login } + body + createdAt + } + } + } + } + } + } + }`, + { owner, repo, prNumber }, + ); + + const comments: PrComment[] = []; + + for (const thread of threadData.repository.pullRequest.reviewThreads.nodes) { + if (thread.isResolved || thread.isOutdated) continue; + for (const comment of thread.comments.nodes) { + const author = comment.author?.login ?? "unknown"; + comments.push({ + id: comment.databaseId, + author, + body: comment.body, + createdAt: comment.createdAt, + type: "review_thread", + isBot: checkIsBot(author, comment.body), + }); + } + } + + // 2. Issue comments via REST + const { data: issueComments } = await octokit.issues.listComments({ + owner, + repo, + issue_number: prNumber, + per_page: 100, + }); + + for (const comment of issueComments) { + const author = comment.user?.login ?? "unknown"; + const body = comment.body ?? ""; + comments.push({ + id: comment.id, + author, + body, + createdAt: comment.created_at, + type: "issue_comment", + isBot: checkIsBot(author, body), + }); + } + + // 3. Review bodies via REST + const { data: reviews } = await octokit.pulls.listReviews({ + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + + for (const review of reviews) { + if (!review.body) continue; + const author = review.user?.login ?? "unknown"; + comments.push({ + id: review.id, + author, + body: review.body, + createdAt: review.submitted_at ?? "", + type: "review_body", + isBot: checkIsBot(author, review.body), + }); + } + + return comments; +} + +export async function getPrComments( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, + cutoffDate?: string, +): Promise<{ all: PrComment[]; new: PrComment[]; previous: PrComment[] }> { + const allComments = await fetchAllPrComments(octokit, owner, repo, prNumber); + const humanComments = allComments.filter((c) => !c.isBot); + + if (cutoffDate) { + const cutoff = new Date(cutoffDate); + const newComments = humanComments.filter((c) => new Date(c.createdAt) > cutoff); + const previousComments = humanComments.filter((c) => new Date(c.createdAt) <= cutoff); + return { all: humanComments, new: newComments, previous: previousComments }; + } + + return { all: humanComments, new: humanComments, previous: [] }; +} + +export async function checkForNewHumanComments( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, + latestCommitDate: string, +): Promise { + const { new: newComments } = await getPrComments(octokit, owner, repo, prNumber, latestCommitDate); + return newComments.length > 0; +} + +/** + * Count how many times a specific workflow has run since the last human + * interaction on a PR. Counts bot comments matching the workflow marker. + * For PRs with no human interaction, counts from the beginning. + */ +export async function countWorkflowRunsSinceLastHumanInteraction( + octokit: Octokit, + owner: string, + repo: string, + prNumber: number, + workflowMarker: string, +): Promise { + const allComments = await fetchAllPrComments(octokit, owner, repo, prNumber); + + // Find latest human interaction date + const humanComments = allComments.filter((c) => !c.isBot); + let latestHumanDate: Date | null = null; + for (const c of humanComments) { + const d = new Date(c.createdAt); + if (!latestHumanDate || d > latestHumanDate) { + latestHumanDate = d; + } + } + const cutoff = latestHumanDate ?? new Date(0); + + // Count bot comments matching the workflow marker since cutoff + return allComments.filter((c) => + c.isBot && + c.body.includes(workflowMarker) && + new Date(c.createdAt) > cutoff + ).length; +} diff --git a/scripts/eternity-loop/index.ts b/scripts/eternity-loop/index.ts new file mode 100755 index 0000000..a5b5a0d --- /dev/null +++ b/scripts/eternity-loop/index.ts @@ -0,0 +1,165 @@ +#!/usr/bin/env bun + +import { parseArgs } from "util"; +import { isInsideSession, bootstrap, cleanupWorktree } from "./bootstrap"; +import { loadSettings, saveSettings } from "./config"; +import { log, logErr, logDebug, logWorkflow } from "./logger"; +import { LinearProvider } from "./providers/linear"; +import { ReviewWorkflow } from "./workflows/review"; +import { CiFixWorkflow } from "./workflows/ci-fix"; +import { NewFeatureWorkflow } from "./workflows/new-feature"; +import { runRalph } from "./ralph"; +import { loadSkillGuidelines } from "./prompts"; +import { join, dirname } from "node:path"; +import type { Settings, WorkflowContext } from "./types"; +import type { Workflow } from "./workflows/types"; + +// Export env vars for child processes +process.env.DISABLE_PUSHOVER_NOTIFICATIONS = "true"; +process.env.RALPH_LOOP = "true"; + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + "max-iterations": { type: "string", default: "50" }, + }, + allowPositionals: true, +}); + +const maxIterations = parseInt(values["max-iterations"] ?? "50", 10); + +// If not inside session, bootstrap into tmux +if (!isInsideSession()) { + await bootstrap(process.argv.slice(2)); + process.exit(0); +} + +// Register cleanup handlers +const cleanup = async () => { + log("[loop] Cleaning up worktree..."); + await cleanupWorktree(); + process.exit(0); +}; + +process.on("SIGINT", cleanup); +process.on("SIGTERM", cleanup); + +// Determine working directory and repo root +const workDir = process.cwd(); +const repoRoot = process.env.ETERNITY_LOOP_REPO_ROOT ?? workDir; + +// Ensure settings +async function ensureSettings(provider: LinearProvider): Promise { + let settings = await loadSettings(repoRoot); + if (settings) { + logDebug("[loop] Loaded existing settings"); + return settings; + } + + log("[loop] No settings found, running interactive setup..."); + + const { select } = await import("@inquirer/prompts"); + const teamsAndProjects = await provider.fetchTeamsAndProjects(); + + let teamId: string; + if (teamsAndProjects.length === 1) { + teamId = teamsAndProjects[0].teamId; + log(`[loop] Auto-selected team: ${teamsAndProjects[0].teamName}`); + } else { + teamId = await select({ + message: "Select a Linear team:", + choices: teamsAndProjects.map((t) => ({ name: t.teamName, value: t.teamId })), + }); + } + + const team = teamsAndProjects.find((t) => t.teamId === teamId)!; + let projectId = ""; + if (team.projects.length > 0) { + projectId = await select({ + message: "Select a project (or none):", + choices: [ + { name: "(no project filter)", value: "" }, + ...team.projects.map((p) => ({ name: p.name, value: p.id })), + ], + }); + } + + settings = { + teamId, + projectId, + workingDirectory: workDir, + }; + + await saveSettings(repoRoot, settings); + log("[loop] Settings saved"); + return settings; +} + +// Main +async function main() { + const apiKey = process.env.LINEAR_API_KEY; + if (!apiKey) { + logErr("[loop] LINEAR_API_KEY not set"); + process.exit(1); + } + + const provider = new LinearProvider(apiKey); + const settings = await ensureSettings(provider); + + const ralphDir = join(workDir, "scripts/ralph"); + const promptsDir = join(dirname(import.meta.path), "eternity-loop-prompts"); + const skillGuidelines = await loadSkillGuidelines(); + + const ctx: WorkflowContext = { + workDir, + ralphDir, + settings, + tool: "claude", + maxIterations, + promptsDir, + skillGuidelines, + }; + + const workflows: Workflow[] = [ + new ReviewWorkflow(), + new CiFixWorkflow(), + new NewFeatureWorkflow(), + ].sort((a, b) => a.priority - b.priority); + + log(`[loop] Starting eternity loop (max iterations per ralph run: ${maxIterations})`); + + while (true) { + let workFound = false; + + for (const workflow of workflows) { + logDebug(`[loop] Checking workflow: ${workflow.name} (priority ${workflow.priority})`); + + try { + const issue = await workflow.check(ctx, provider); + if (!issue) continue; + + workFound = true; + logWorkflow(workflow.name, `[loop] Workflow "${workflow.name}" found work: ${issue.identifier} - ${issue.title}`); + + await workflow.prepare(ctx, issue); + const exitCode = await runRalph({ projectDir: workDir, maxIterations, workflowName: workflow.name }); + await workflow.finalize(ctx, issue, exitCode); + + logWorkflow(workflow.name, `[loop] Workflow "${workflow.name}" completed for ${issue.identifier}`); + break; // Restart workflow priority loop + } catch (err) { + logErr(`[loop] Error in workflow "${workflow.name}": ${err}`); + } + } + + if (!workFound) { + log("[loop] No work found. Sleeping 120s..."); + await Bun.sleep(120_000); + } + } +} + +main().catch((err) => { + logErr(`[loop] Fatal error: ${err}`); + process.exit(1); +}); diff --git a/scripts/eternity-loop/logger.ts b/scripts/eternity-loop/logger.ts new file mode 100644 index 0000000..b92e15b --- /dev/null +++ b/scripts/eternity-loop/logger.ts @@ -0,0 +1,65 @@ +import { join } from "node:path"; + +// ANSI color codes +const RESET = "\x1b[0m"; +const DIM = "\x1b[2m"; +const GREEN = "\x1b[32m"; +const YELLOW = "\x1b[33m"; +const MAGENTA = "\x1b[35m"; + +const WORKFLOW_COLORS: Record = { + "new-feature": GREEN, + "ci-fix": YELLOW, + "review": MAGENTA, +}; + +function timestamp(): string { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `[${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}]`; +} + +export function log(...args: unknown[]): void { + console.log(timestamp(), ...args); +} + +export function logErr(...args: unknown[]): void { + console.error(timestamp(), ...args); +} + +export function logDebug(...args: unknown[]): void { + console.log(`${DIM}${timestamp()}`, ...args, RESET); +} + +export function logWorkflow(workflowName: string, ...args: unknown[]): void { + const color = WORKFLOW_COLORS[workflowName] ?? ""; + console.log(`${color}${timestamp()}`, ...args, RESET); +} + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +export function startSpinner(message: string): { stop: () => void } { + let i = 0; + const interval = setInterval(() => { + process.stdout.write(`\r${DIM}${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]} ${message}${RESET}`); + }, 80); + + return { + stop() { + clearInterval(interval); + process.stdout.write("\r\x1b[K"); // clear line + }, + }; +} + +export async function logPrdEntryCount(workflowName: string, prdPath: string): Promise { + try { + const file = Bun.file(prdPath); + if (!(await file.exists())) return; + const prd = await file.json(); + const count = Array.isArray(prd.userStories) ? prd.userStories.length : 0; + logWorkflow(workflowName, `[${workflowName}] Generated prd.json with ${count} user stories`); + } catch { + // Non-critical, skip if prd.json can't be read + } +} diff --git a/scripts/eternity-loop/prompts.ts b/scripts/eternity-loop/prompts.ts new file mode 100644 index 0000000..8661574 --- /dev/null +++ b/scripts/eternity-loop/prompts.ts @@ -0,0 +1,20 @@ +import { join, dirname } from "node:path"; + +const promptsDir = join(dirname(import.meta.path), "eternity-loop-prompts"); +const skillPath = join(dirname(import.meta.path), "..", "..", "general", "skills", "ralph", "SKILL.md"); + +export async function readPrompt(filename: string): Promise { + const file = Bun.file(join(promptsDir, filename)); + if (await file.exists()) { + return file.text(); + } + return `# ${filename} (prompt file not found)`; +} + +export async function loadSkillGuidelines(): Promise { + const file = Bun.file(skillPath); + if (await file.exists()) { + return file.text(); + } + return "# SKILL.md (skill guidelines not found)"; +} diff --git a/scripts/eternity-loop/providers/linear.ts b/scripts/eternity-loop/providers/linear.ts new file mode 100644 index 0000000..803d215 --- /dev/null +++ b/scripts/eternity-loop/providers/linear.ts @@ -0,0 +1,90 @@ +import { LinearClient } from "@linear/sdk"; +import type { Issue } from "../types"; +import type { IssueFilter, IssueProvider } from "./types"; + +export class LinearProvider implements IssueProvider { + private client: LinearClient; + + constructor(apiKey: string) { + this.client = new LinearClient({ apiKey }); + } + + async fetchTeamsAndProjects(): Promise< + Array<{ teamId: string; teamName: string; projects: Array<{ id: string; name: string }> }> + > { + const teamsConnection = await this.client.teams({ first: 50 }); + const results: Array<{ + teamId: string; + teamName: string; + projects: Array<{ id: string; name: string }>; + }> = []; + + for (const team of teamsConnection.nodes) { + const projectsConnection = await team.projects({ first: 50 }); + results.push({ + teamId: team.id, + teamName: team.name, + projects: projectsConnection.nodes.map((p) => ({ id: p.id, name: p.name })), + }); + } + + return results; + } + + async queryIssues(filter: IssueFilter, limit = 50): Promise { + const issueFilter: Record = { + team: { id: { eq: filter.teamId } }, + }; + + if (filter.projectId) { + issueFilter.project = { id: { eq: filter.projectId } }; + } + + if (filter.stateName) { + issueFilter.state = { name: { eq: filter.stateName } }; + } + + if (filter.labels && filter.labels.length > 0) { + issueFilter.labels = { + some: { name: { in: filter.labels } }, + }; + } + + const connection = await this.client.issues({ + filter: issueFilter, + first: limit, + }); + + const issues: Issue[] = []; + for (const node of connection.nodes) { + const state = await node.state; + issues.push({ + uuid: node.id, + identifier: node.identifier, + title: node.title, + description: node.description ?? "", + url: node.url, + branchName: node.branchName, + stateName: state?.name ?? "", + }); + } + + return issues; + } + + async transitionIssue(uuid: string, stateName: string): Promise { + const issue = await this.client.issue(uuid); + const team = await issue.team; + if (!team) { + throw new Error(`No team found for issue "${uuid}"`); + } + const statesConnection = await team.states({ first: 50 }); + + const targetState = statesConnection.nodes.find((s) => s.name === stateName); + if (!targetState) { + throw new Error(`Workflow state "${stateName}" not found for team "${team.name}"`); + } + + await issue.update({ stateId: targetState.id }); + } +} diff --git a/scripts/eternity-loop/providers/types.ts b/scripts/eternity-loop/providers/types.ts new file mode 100644 index 0000000..ea811db --- /dev/null +++ b/scripts/eternity-loop/providers/types.ts @@ -0,0 +1,16 @@ +import type { Issue } from "../types"; + +export interface IssueFilter { + teamId: string; + projectId?: string; + stateName?: string; + labels?: string[]; +} + +export interface IssueProvider { + fetchTeamsAndProjects(): Promise< + Array<{ teamId: string; teamName: string; projects: Array<{ id: string; name: string }> }> + >; + queryIssues(filter: IssueFilter, limit?: number): Promise; + transitionIssue(uuid: string, stateName: string): Promise; +} diff --git a/scripts/eternity-loop/ralph.ts b/scripts/eternity-loop/ralph.ts new file mode 100644 index 0000000..8ec4d59 --- /dev/null +++ b/scripts/eternity-loop/ralph.ts @@ -0,0 +1,141 @@ +import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { log, logWorkflow, startSpinner } from "./logger"; + +interface UserStory { + id: string; + title: string; + passes: boolean; +} + +interface Prd { + userStories: UserStory[]; +} + +async function readPrd(prdFile: string): Promise { + try { + const file = Bun.file(prdFile); + if (!(await file.exists())) return null; + return await file.json() as Prd; + } catch { + return null; + } +} + +export async function runRalph(options: { + projectDir: string; + maxIterations: number; + workflowName?: string; +}): Promise { + const { projectDir, maxIterations, workflowName } = options; + const ralphDir = join(projectDir, "scripts/ralph"); + const prdFile = join(ralphDir, "prd.json"); + const progressFile = join(ralphDir, "progress.txt"); + const archiveDir = join(ralphDir, "archive"); + const claudeMdFile = join(ralphDir, "CLAUDE.md"); + + await mkdir(ralphDir, { recursive: true }); + + // Archive previous run if progress.txt exists + const progressExists = await Bun.file(progressFile).exists(); + if (progressExists) { + const date = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + const archiveFolder = join(archiveDir, date); + await mkdir(archiveFolder, { recursive: true }); + + const prdExists = await Bun.file(prdFile).exists(); + if (prdExists) { + await Bun.$`cp ${prdFile} ${archiveFolder}/`.quiet(); + } + await Bun.$`cp ${progressFile} ${archiveFolder}/`.quiet(); + } + + // Start fresh progress.txt + await Bun.write(progressFile, `# Ralph Progress Log\nStarted: ${new Date().toString()}\n---\n`); + + const logColored = workflowName + ? (...args: unknown[]) => logWorkflow(workflowName, ...args) + : (...args: unknown[]) => log(...args); + + logColored("[ralph] Starting Ralph - Max iterations:", maxIterations); + + for (let i = 1; i <= maxIterations; i++) { + logColored(`[ralph] ===============================================================`); + logColored(`[ralph] Iteration ${i} of ${maxIterations}`); + logColored(`[ralph] ===============================================================`); + + // Log current step before iteration + const prdBefore = await readPrd(prdFile); + if (prdBefore) { + const nextStory = prdBefore.userStories.find((s) => !s.passes); + if (nextStory) { + logColored(`[ralph] Starting: ${nextStory.id} - ${nextStory.title}`); + } + } + + logColored(`[ralph] Running Claude, this could take a while...`); + const spinner = startSpinner(`Ralph iteration ${i}/${maxIterations} running...`); + let output: string; + try { + output = await Bun.$`claude --dangerously-skip-permissions --print < ${claudeMdFile}` + .cwd(projectDir) + .env({ + ...process.env, + DISABLE_PUSHOVER_NOTIFICATIONS: "true", + RALPH_LOOP: "true", + }) + .text(); + } catch (e: unknown) { + const err = e as { stdout?: { toString(): string } }; + output = err.stdout?.toString() ?? ""; + logColored(`[ralph] Iteration ${i} exited with error, continuing...`); + } finally { + spinner.stop(); + } + + // Log progress after iteration + const prdAfter = await readPrd(prdFile); + if (prdAfter) { + const passed = prdAfter.userStories.filter((s) => s.passes).length; + const total = prdAfter.userStories.length; + logColored(`[ralph] Progress: ${passed}/${total} user stories complete`); + } + + // Check for completion signal + if (output.includes("COMPLETE")) { + logColored(`[ralph] Ralph completed all tasks at iteration ${i} of ${maxIterations}!`); + await sendNotification( + `Ralph completed all tasks! ✅ (iteration ${i}/${maxIterations} in ${projectDir})`, + ); + return 0; + } + + logColored(`[ralph] Iteration ${i} complete. Continuing...`); + } + + logColored(`[ralph] Ralph reached max iterations (${maxIterations}) without completing all tasks.`); + await sendNotification( + `Ralph reached max iterations (${maxIterations}) in ${projectDir} ⚠️`, + ); + return 1; +} + +async function sendNotification(message: string): Promise { + const scriptPath = join( + process.env.HOME ?? "", + ".claude/plugins/cache/kingstinct-skills/general/1.1.0/scripts/pushover-notification.sh", + ); + const exists = await Bun.file(scriptPath).exists(); + if (!exists) return; + + try { + await Bun.$`MESSAGE=${message} ${scriptPath}`.env({ + ...process.env, + DISABLE_PUSHOVER_NOTIFICATIONS: undefined as unknown as string, + RALPH_LOOP: undefined as unknown as string, + MESSAGE: message, + }).quiet(); + } catch { + // Notification failure is non-critical + } +} diff --git a/scripts/eternity-loop/types.ts b/scripts/eternity-loop/types.ts new file mode 100644 index 0000000..0e1e087 --- /dev/null +++ b/scripts/eternity-loop/types.ts @@ -0,0 +1,33 @@ +export interface CliArgs { + tool: string; + maxIterations: number; +} + +export interface Settings { + teamId: string; + projectId: string; + workingDirectory: string; +} + +export interface Issue { + uuid: string; + identifier: string; + title: string; + description: string; + url: string; + branchName: string; + stateName: string; + prNumber?: number; +} + +export type TaskType = "review" | "ci-fix" | "new"; + +export interface WorkflowContext { + workDir: string; + ralphDir: string; + settings: Settings; + tool: string; + maxIterations: number; + promptsDir: string; + skillGuidelines: string; +} diff --git a/scripts/eternity-loop/workflows/ci-fix.ts b/scripts/eternity-loop/workflows/ci-fix.ts new file mode 100644 index 0000000..d319c1a --- /dev/null +++ b/scripts/eternity-loop/workflows/ci-fix.ts @@ -0,0 +1,132 @@ +import type { Workflow } from "./types"; +import type { IssueProvider } from "../providers/types"; +import type { Issue, WorkflowContext } from "../types"; +import { logDebug, logWorkflow, logPrdEntryCount } from "../logger"; +import { checkoutBranch, pushBranch, getHeadSha } from "../git"; +import { createGitHubClient, getRepoInfo } from "../github/client"; +import { findPrByBranch, postPrComment } from "../github/pr"; +import { checkPrHasCiFailures, getCiFailureDetails, recordFixedSha } from "../github/ci"; +import { readPrompt } from "../prompts"; +import { ClaudeCliRunner } from "../ai-runner"; +import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; + +export class CiFixWorkflow implements Workflow { + name = "ci-fix"; + priority = 2; + + async check(ctx: WorkflowContext, provider: IssueProvider): Promise { + logDebug("[ci-fix] Checking for CI failures..."); + + const inReview = await provider.queryIssues({ + teamId: ctx.settings.teamId, + projectId: ctx.settings.projectId, + stateName: "In Review", + labels: ["prd"], + }); + + const inProgress = await provider.queryIssues({ + teamId: ctx.settings.teamId, + projectId: ctx.settings.projectId, + stateName: "In Progress", + labels: ["prd"], + }); + + const candidates = [...inReview, ...inProgress]; + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + const settingsDir = join(ctx.workDir, ".eternity-loop"); + + let failureCount = 0; + for (const issue of candidates) { + const pr = await findPrByBranch(octokit, owner, repo, issue.branchName); + if (!pr) continue; + + const hasCiFailures = await checkPrHasCiFailures( + octokit, owner, repo, issue.branchName, settingsDir, pr.number, + ); + + if (hasCiFailures) { + failureCount++; + logWorkflow("ci-fix", `[ci-fix] Found CI failures for ${issue.identifier} (PR #${pr.number})`); + issue.prNumber = pr.number; + return issue; + } + } + + if (failureCount === 0) { + logDebug(`[ci-fix] Found 0 CI failures`); + } + + return null; + } + + async prepare(ctx: WorkflowContext, issue: Issue): Promise { + logWorkflow("ci-fix", `[ci-fix] Preparing CI fix workflow for ${issue.identifier}`); + + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + // Collect CI failure details + const ciDetails = await getCiFailureDetails( + octokit, owner, repo, issue.prNumber!, issue.branchName, + ); + + // Check out the issue branch + await checkoutBranch(ctx.workDir, issue.branchName); + + // Record SHA to prevent re-processing same commit + const settingsDir = join(ctx.workDir, ".eternity-loop"); + await mkdir(settingsDir, { recursive: true }); + + const headSha = await getHeadSha(ctx.workDir, `origin/${issue.branchName}`); + await recordFixedSha(settingsDir, headSha); + + // Invoke AI runner with create-ci-fix-prd prompt + const prompt = await readPrompt("create-ci-fix-prd.md"); + const runner = new ClaudeCliRunner(); + const fullPrompt = [ + prompt, + `\n## Issue\n- Identifier: ${issue.identifier}\n- Title: ${issue.title}\n- Branch: ${issue.branchName}\n- PR #${issue.prNumber}`, + `\n## CI Failure Details\n${ciDetails}`, + `\n## Instructions\nWrite prd.json to: ${ctx.ralphDir}/prd.json\nUse branch name: ${issue.branchName}\nUse project name: ${issue.identifier} - CI Fix`, + ].join("\n"); + + await runner.run(fullPrompt, ctx.workDir); + + // Log PRD entry count + await logPrdEntryCount("ci-fix", join(ctx.ralphDir, "prd.json")); + + // Write CLAUDE.md: standard ralph-claude-md.md + CI failure context appended + const claudeMdContent = await readPrompt("ralph-claude-md.md"); + const ciClaudeMd = [ + claudeMdContent, + "\n\n## CI Failure Context\n", + ciDetails, + ].join(""); + await Bun.write(join(ctx.ralphDir, "CLAUDE.md"), ciClaudeMd); + + logWorkflow("ci-fix", `[ci-fix] Prepared CI fix PRD for ${issue.identifier}`); + } + + async finalize(ctx: WorkflowContext, issue: Issue, ralphExitCode: number): Promise { + logWorkflow("ci-fix", `[ci-fix] Finalizing CI fix for ${issue.identifier} (exit: ${ralphExitCode})`); + + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + // Push changes to origin + await pushBranch(ctx.workDir, issue.branchName); + + // Post progress.txt as PR comment + const progressFile = join(ctx.ralphDir, "progress.txt"); + const progress = await Bun.file(progressFile).text(); + await postPrComment( + octokit, owner, repo, issue.prNumber!, + `🤖 **eternity-loop bot:** CI fix applied.\n\n
Progress log\n\n${progress}\n\n
`, + ); + + logWorkflow("ci-fix", `[ci-fix] Finalized CI fix for ${issue.identifier} — https://github.com/${owner}/${repo}/pull/${issue.prNumber}`); + } +} diff --git a/scripts/eternity-loop/workflows/new-feature.ts b/scripts/eternity-loop/workflows/new-feature.ts new file mode 100644 index 0000000..ed3248b --- /dev/null +++ b/scripts/eternity-loop/workflows/new-feature.ts @@ -0,0 +1,116 @@ +import type { Workflow } from "./types"; +import type { IssueProvider } from "../providers/types"; +import type { Issue, WorkflowContext } from "../types"; +import { logDebug, logWorkflow, logPrdEntryCount } from "../logger"; +import { ensureMainBranch, checkoutBranch, pushBranch } from "../git"; +import { createGitHubClient, getRepoInfo } from "../github/client"; +import { createPr, postPrComment, findPrByBranch } from "../github/pr"; +import { readPrompt } from "../prompts"; +import { ClaudeCliRunner } from "../ai-runner"; +import { join } from "node:path"; + +export class NewFeatureWorkflow implements Workflow { + name = "new-feature"; + priority = 3; + + async check(ctx: WorkflowContext, provider: IssueProvider): Promise { + logDebug("[new-feature] Checking for Todo issues with prd label..."); + + const issues = await provider.queryIssues({ + teamId: ctx.settings.teamId, + projectId: ctx.settings.projectId, + stateName: "Todo", + labels: ["prd"], + }); + + const count = issues.length; + if (count === 0) { + logDebug(`[new-feature] Found 0 Todo issues to implement`); + return null; + } + + logWorkflow("new-feature", `[new-feature] Found ${count} Todo issues to implement`); + logWorkflow("new-feature", `[new-feature] Found Todo issue: ${issues[0].identifier}`); + return issues[0]; + } + + async prepare(ctx: WorkflowContext, issue: Issue): Promise { + logWorkflow("new-feature", `[new-feature] Preparing new feature workflow for ${issue.identifier}`); + + // Reset to main branch + await ensureMainBranch(ctx.workDir); + + // Transition issue to In Progress + const { LinearProvider } = await import("../providers/linear"); + const provider = new LinearProvider(process.env.LINEAR_API_KEY!); + await provider.transitionIssue(issue.uuid, "In Progress"); + + // Invoke AI runner with create-prd prompt + const prompt = await readPrompt("create-prd.md"); + const runner = new ClaudeCliRunner(); + const fullPrompt = [ + prompt, + `\n## Issue\n- Identifier: ${issue.identifier}\n- Title: ${issue.title}\n- Description: ${issue.description}\n- URL: ${issue.url}\n- Branch: ${issue.branchName}`, + `\n## Instructions\nWrite prd.json to: ${ctx.ralphDir}/prd.json\nUse branch name: ${issue.branchName}\nUse project name: ${issue.identifier} - ${issue.title}`, + ].join("\n"); + + await runner.run(fullPrompt, ctx.workDir); + + // Log PRD entry count + await logPrdEntryCount("new-feature", join(ctx.ralphDir, "prd.json")); + + // Create and checkout feature branch + await checkoutBranch(ctx.workDir, issue.branchName); + + // Write CLAUDE.md from ralph-claude-md.md + const claudeMdContent = await readPrompt("ralph-claude-md.md"); + await Bun.write(join(ctx.ralphDir, "CLAUDE.md"), claudeMdContent); + + logWorkflow("new-feature", `[new-feature] Prepared feature PRD for ${issue.identifier}`); + } + + async finalize(ctx: WorkflowContext, issue: Issue, ralphExitCode: number): Promise { + logWorkflow("new-feature", `[new-feature] Finalizing new feature for ${issue.identifier} (exit: ${ralphExitCode})`); + + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + // Push changes to origin + await pushBranch(ctx.workDir, issue.branchName); + + if (ralphExitCode === 0) { + // Create regular PR + const pr = await createPr(octokit, owner, repo, { + title: `${issue.identifier}: ${issue.title}`, + body: `Resolves ${issue.identifier}\n\n${issue.url}\n\nImplemented by eternity-loop agent.`, + head: issue.branchName, + draft: false, + }); + + // Post progress.txt as PR comment + const progressFile = join(ctx.ralphDir, "progress.txt"); + const progress = await Bun.file(progressFile).text(); + await postPrComment( + octokit, owner, repo, pr.number, + `🤖 **eternity-loop bot:** Feature implementation complete.\n\n
Progress log\n\n${progress}\n\n
`, + ); + + logWorkflow("new-feature", `[new-feature] Created PR ${pr.url}`); + } else { + // Create draft PR via AI runner + const runner = new ClaudeCliRunner(); + const prPrompt = await readPrompt("create-pr.md"); + const fullPrompt = [ + prPrompt, + `\n## Issue\n- Identifier: ${issue.identifier}\n- Title: ${issue.title}\n- URL: ${issue.url}\n- Branch: ${issue.branchName}`, + `\nCreate this as a DRAFT PR since the agent did not complete all tasks.`, + ].join("\n"); + + await runner.run(fullPrompt, ctx.workDir); + + const draftPr = await findPrByBranch(octokit, owner, repo, issue.branchName); + const draftUrl = draftPr ? draftPr.url : `https://github.com/${owner}/${repo}`; + logWorkflow("new-feature", `[new-feature] Created draft PR for ${issue.identifier} — ${draftUrl}`); + } + } +} diff --git a/scripts/eternity-loop/workflows/review.ts b/scripts/eternity-loop/workflows/review.ts new file mode 100644 index 0000000..a22448d --- /dev/null +++ b/scripts/eternity-loop/workflows/review.ts @@ -0,0 +1,148 @@ +import type { Workflow } from "./types"; +import type { IssueProvider } from "../providers/types"; +import type { Issue, WorkflowContext } from "../types"; +import { logDebug, logWorkflow, logPrdEntryCount } from "../logger"; +import { checkoutBranch, pushBranch, getLatestCommitDate } from "../git"; +import { createGitHubClient, getRepoInfo } from "../github/client"; +import { findPrByBranch, getPrComments, postPrComment, checkForNewHumanComments, countWorkflowRunsSinceLastHumanInteraction } from "../github/pr"; +import { readPrompt } from "../prompts"; +import { ClaudeCliRunner } from "../ai-runner"; +import { join } from "node:path"; + +export class ReviewWorkflow implements Workflow { + name = "review"; + priority = 1; + + async check(ctx: WorkflowContext, provider: IssueProvider): Promise { + logDebug("[review] Checking for issues needing review attention..."); + + // Query for In Review issues with prd label + const inReview = await provider.queryIssues({ + teamId: ctx.settings.teamId, + projectId: ctx.settings.projectId, + stateName: "In Review", + labels: ["prd"], + }); + + // Also check In Progress issues with prd label + const inProgress = await provider.queryIssues({ + teamId: ctx.settings.teamId, + projectId: ctx.settings.projectId, + stateName: "In Progress", + labels: ["prd"], + }); + + const candidates = [...inReview, ...inProgress]; + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + let commentCount = 0; + for (const issue of candidates) { + const pr = await findPrByBranch(octokit, owner, repo, issue.branchName); + if (!pr) continue; + + const latestCommitDate = await getLatestCommitDate(ctx.workDir, issue.branchName); + const hasNewComments = await checkForNewHumanComments( + octokit, owner, repo, pr.number, latestCommitDate, + ); + + if (hasNewComments) { + // Check if we've already hit the attempt limit since last human interaction + const runs = await countWorkflowRunsSinceLastHumanInteraction( + octokit, owner, repo, pr.number, "Review changes applied.", + ); + if (runs >= 3) { + logDebug(`[review] Skipping ${issue.identifier}: reached 3 review attempts since last human interaction`); + continue; + } + + commentCount++; + logWorkflow("review", `[review] Found issue ${issue.identifier} with new PR comments`); + issue.prNumber = pr.number; + return issue; + } + } + + if (commentCount === 0) { + logDebug(`[review] Found 0 issues with new PR comments`); + } + + return null; + } + + async prepare(ctx: WorkflowContext, issue: Issue): Promise { + logWorkflow("review", `[review] Preparing review workflow for ${issue.identifier}`); + + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + // Check out the issue branch + await checkoutBranch(ctx.workDir, issue.branchName); + + // Collect PR comments split by cutoff date + const latestCommitDate = await getLatestCommitDate(ctx.workDir, issue.branchName); + const comments = await getPrComments( + octokit, owner, repo, issue.prNumber!, latestCommitDate, + ); + + const newCommentsText = comments.new + .map((c) => `**${c.author}** (${c.type}):\n${c.body}`) + .join("\n\n---\n\n"); + + const previousCommentsText = comments.previous + .map((c) => `**${c.author}** (${c.type}):\n${c.body}`) + .join("\n\n---\n\n"); + + // Invoke AI runner with create-review-prd prompt + const prompt = await readPrompt("create-review-prd.md"); + const runner = new ClaudeCliRunner(); + const fullPrompt = [ + prompt, + `\n## Issue\n- Identifier: ${issue.identifier}\n- Title: ${issue.title}\n- Branch: ${issue.branchName}\n- PR #${issue.prNumber}`, + `\n## New Comments (after latest commit)\n${newCommentsText || "(none)"}`, + `\n## Previous Comments\n${previousCommentsText || "(none)"}`, + `\n## Instructions\nWrite prd.json to: ${ctx.ralphDir}/prd.json\nUse branch name: ${issue.branchName}\nUse project name: ${issue.identifier} - ${issue.title} (Review)`, + ].join("\n"); + + await runner.run(fullPrompt, ctx.workDir); + + // Log PRD entry count + await logPrdEntryCount("review", join(ctx.ralphDir, "prd.json")); + + // Write CLAUDE.md from ralph-claude-md.md + const claudeMdContent = await readPrompt("ralph-claude-md.md"); + await Bun.write(join(ctx.ralphDir, "CLAUDE.md"), claudeMdContent); + + logWorkflow("review", `[review] Prepared review PRD for ${issue.identifier}`); + } + + async finalize(ctx: WorkflowContext, issue: Issue, ralphExitCode: number): Promise { + logWorkflow("review", `[review] Finalizing review workflow for ${issue.identifier} (exit: ${ralphExitCode})`); + + const octokit = createGitHubClient(); + const { owner, repo } = await getRepoInfo(ctx.workDir); + + // Push changes to origin + await pushBranch(ctx.workDir, issue.branchName); + + // Reply to PR comments via AI runner + const replyPrompt = await readPrompt("reply-to-pr-comments.md"); + const runner = new ClaudeCliRunner(); + const fullReplyPrompt = [ + replyPrompt, + `\n## Context\n- Repository: ${owner}/${repo}\n- PR #${issue.prNumber}\n- Branch: ${issue.branchName}`, + ].join("\n"); + + await runner.run(fullReplyPrompt, ctx.workDir); + + // Post progress.txt as PR comment + const progressFile = join(ctx.ralphDir, "progress.txt"); + const progress = await Bun.file(progressFile).text(); + await postPrComment( + octokit, owner, repo, issue.prNumber!, + `🤖 **eternity-loop bot:** Review changes applied.\n\n
Progress log\n\n${progress}\n\n
`, + ); + + logWorkflow("review", `[review] Finalized review for ${issue.identifier} — https://github.com/${owner}/${repo}/pull/${issue.prNumber}`); + } +} diff --git a/scripts/eternity-loop/workflows/types.ts b/scripts/eternity-loop/workflows/types.ts new file mode 100644 index 0000000..caad6c0 --- /dev/null +++ b/scripts/eternity-loop/workflows/types.ts @@ -0,0 +1,10 @@ +import type { IssueProvider } from "../providers/types"; +import type { Issue, WorkflowContext } from "../types"; + +export interface Workflow { + name: string; + priority: number; + check(ctx: WorkflowContext, provider: IssueProvider): Promise; + prepare(ctx: WorkflowContext, issue: Issue): Promise; + finalize(ctx: WorkflowContext, issue: Issue, ralphExitCode: number): Promise; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c5aa0c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "noEmit": true + }, + "include": ["scripts/**/*.ts"] +}