├── logo.png ├── .env ├── src ├── vite-env.d.ts ├── assets │ ├── logo.png │ └── react.svg ├── setupTests.ts ├── components │ ├── LoadingSpinner.tsx │ ├── ErrorMessage.tsx │ ├── ActivityFilter.tsx │ ├── ActivityFilter.test.tsx │ ├── ActivityList.tsx │ ├── DateRangeFilter.tsx │ ├── ActivityPieChart.tsx │ ├── ActivityChart.tsx │ ├── ActivityPieChart.test.tsx │ ├── ActivityChart.test.tsx │ ├── DateRangeFilter.test.tsx │ └── ActivityList.test.tsx ├── types │ ├── eslint-plugins.d.ts │ └── index.ts ├── main.tsx ├── services │ ├── github.ts │ └── github.test.ts ├── index.css ├── test │ ├── testUtils.ts │ └── setup.ts ├── __integration_tests__ │ └── github.test.ts ├── App.tsx └── App.css ├── public ├── logo.png ├── favicon.ico └── cache │ └── bot-activities.json ├── postcss.config.js ├── vercel.json ├── .vercel └── project.json ├── .gitignore ├── vite.config.ts ├── scripts ├── tsconfig.json ├── package.json └── src │ ├── types.ts │ ├── cache-github-data.ts │ ├── github-api.test.ts │ ├── monitor-cache.ts │ └── github-api.ts ├── vitest.config.ts ├── index.html ├── .github └── workflows │ ├── test.yml │ ├── lint.yml │ ├── dependabot.yml │ ├── ci.yml │ └── openhands-resolver.yml ├── tsconfig.json ├── tsconfig.tsbuildinfo ├── api ├── status.js └── cron.js ├── tsconfig.cache.json ├── tsconfig.node.json ├── tailwind.config.ts ├── LICENSE ├── tsconfig.app.json ├── eslint.config.ts ├── eslint.config.js ├── package.json ├── test └── services │ └── github-api.test.ts └── README.md /logo.png: -------------------------------------------------------------------------------- 1 | 404: Not Found -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_GITHUB_TOKEN=$GITHUB_TOKEN -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenHands/openhands-agent-monitor/HEAD/public/logo.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenHands/openhands-agent-monitor/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenHands/openhands-agent-monitor/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import '@testing-library/jest-dom'; 3 | import './test/setup'; -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "crons": [ 3 | { 4 | "path": "/api/cron", 5 | "schedule": "0 */12 * * *" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /.vercel/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "buildCommand": "npm run build:all", 3 | "devCommand": "npm run dev", 4 | "installCommand": "npm install" 5 | } -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | export function LoadingSpinner(): React.JSX.Element { 2 | return ( 3 |
4 |
5 |

Loading activities...

6 |
7 | ); 8 | } -------------------------------------------------------------------------------- /src/types/eslint-plugins.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'eslint-plugin-react-hooks' { 2 | const plugin: any; 3 | export default plugin; 4 | } 5 | 6 | declare module 'eslint-plugin-react-refresh' { 7 | const plugin: any; 8 | export default plugin; 9 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | define: { 8 | 'process.env': JSON.stringify(process.env), 9 | }, 10 | build: { 11 | outDir: 'dist', 12 | sourcemap: true, 13 | copyPublicDir: true, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "strict": true, 8 | "outDir": "./dist", 9 | "rootDir": "./src", 10 | "skipLibCheck": true, 11 | "noEmit": false 12 | }, 13 | "include": ["src/**/*.ts"], 14 | "exclude": ["node_modules"] 15 | } -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | interface ErrorMessageProps { 2 | message: string; 3 | onRetry: () => void; 4 | } 5 | 6 | export function ErrorMessage({ message, onRetry }: ErrorMessageProps): React.JSX.Element { 7 | return ( 8 |
9 |

{message}

10 | 11 |
12 | ); 13 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | const rootElement = document.getElementById('root'); 7 | if (rootElement === null) { 8 | throw new Error('Root element not found'); 9 | } 10 | 11 | createRoot(rootElement).render( 12 | 13 | 14 | , 15 | ) 16 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | globals: true, 9 | environment: 'jsdom', 10 | setupFiles: ['./src/setupTests.ts'], 11 | include: ['test/**/*.{test,spec}.{ts,tsx}', 'src/**/*.{test,spec}.{ts,tsx}'], 12 | }, 13 | }); -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "openhands-agent-monitor-scripts", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "test": "vitest" 7 | }, 8 | "dependencies": { 9 | "node-fetch": "^2.6.7" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "^20.10.4", 13 | "@types/node-fetch": "^2.6.11", 14 | "typescript": "^5.3.3", 15 | "vitest": "^2.1.8" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | OpenHands Agent Activity Monitor 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 18.x 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18.x 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Run tests 26 | run: npm test 27 | env: 28 | CI: true 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 18.x 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18.x 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Run ESLint 26 | run: npm run lint 27 | 28 | - name: Run TypeScript type checking 29 | run: npm run typecheck -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "noEmit": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "types": [] 19 | }, 20 | "include": ["src"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/App.tsx","./src/main.tsx","./src/setupTests.ts","./src/vite-env.d.ts","./src/__integration_tests__/github.test.ts","./src/components/ActivityChart.test.tsx","./src/components/ActivityChart.tsx","./src/components/ActivityFilter.test.tsx","./src/components/ActivityFilter.tsx","./src/components/ActivityList.test.tsx","./src/components/ActivityList.tsx","./src/components/DateRangeFilter.test.tsx","./src/components/DateRangeFilter.tsx","./src/components/ErrorMessage.tsx","./src/components/LoadingSpinner.tsx","./src/services/github.test.ts","./src/services/github.ts","./src/test/setup.ts","./src/test/testUtils.ts","./src/types/eslint-plugins.d.ts","./src/types/index.ts"],"version":"5.6.3"} -------------------------------------------------------------------------------- /api/status.js: -------------------------------------------------------------------------------- 1 | export default async function handler(req, res) { 2 | if (req.method !== 'GET') { 3 | return res.status(405).json({ error: 'Method not allowed' }); 4 | } 5 | 6 | try { 7 | const { readFile } = await import('fs/promises'); 8 | const { join } = await import('path'); 9 | 10 | const statusPath = join('public/cache/status.json'); 11 | const status = JSON.parse(await readFile(statusPath, 'utf8')); 12 | 13 | return res.status(200).json(status); 14 | } catch (error) { 15 | console.error('Failed to read status:', error); 16 | return res.status(500).json({ 17 | error: 'Failed to read cache status', 18 | details: error.message 19 | }); 20 | } 21 | } -------------------------------------------------------------------------------- /tsconfig.cache.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "node", 9 | "isolatedModules": true, 10 | "noEmit": false, 11 | "outDir": "./scripts", 12 | "jsx": "react-jsx", 13 | "strict": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "esModuleInterop": true, 18 | "allowJs": false, 19 | "checkJs": false 20 | }, 21 | "include": ["scripts/github-api.ts", "src/types/index.ts"], 22 | "exclude": ["node_modules"] 23 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 6 | "target": "ES2022", 7 | "lib": ["ES2023"], 8 | "module": "ESNext", 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true, 23 | "types": ["vitest/globals"] 24 | }, 25 | "include": ["vite.config.ts", "vitest.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | 19 | - name: Enable auto-merge for Dependabot PRs 20 | if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor'}} 21 | run: gh pr merge --auto --merge "$PR_URL" 22 | env: 23 | PR_URL: ${{github.event.pull_request.html_url}} 24 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Use Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '18.x' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Type check 26 | run: npm run typecheck 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Lint 31 | run: npm run lint 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build 36 | run: npm run build 37 | 38 | - name: Test 39 | run: npm run test 40 | env: 41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/openhands-resolver.yml: -------------------------------------------------------------------------------- 1 | name: Resolve Issue with OpenHands 2 | 3 | on: 4 | issues: 5 | types: [labeled] 6 | pull_request: 7 | types: [labeled] 8 | issue_comment: 9 | types: [created] 10 | pull_request_review_comment: 11 | types: [created] 12 | pull_request_review: 13 | types: [submitted] 14 | 15 | permissions: 16 | contents: write 17 | pull-requests: write 18 | issues: write 19 | 20 | jobs: 21 | call-openhands-resolver: 22 | uses: All-Hands-AI/OpenHands/.github/workflows/openhands-resolver.yml@main 23 | with: 24 | macro: ${{ vars.OPENHANDS_MACRO || '@openhands-agent' }} 25 | max_iterations: ${{ vars.OPENHANDS_MAX_ITER || 50 }} 26 | secrets: 27 | PAT_TOKEN: ${{ secrets.PAT_TOKEN }} 28 | PAT_USERNAME: ${{ secrets.PAT_USERNAME }} 29 | LLM_MODEL: ${{ secrets.LLM_MODEL }} 30 | LLM_API_KEY: ${{ secrets.LLM_API_KEY }} 31 | LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }} 32 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export type ActivityType = 'issue' | 'pr'; 2 | export type PRStatus = 'no_pr' | 'pr_open' | 'pr_merged' | 'pr_closed'; 3 | export type PRActivityStatus = 'success' | 'failure'; 4 | export type IssueActivityStatus = PRStatus; 5 | export type ActivityStatus = PRActivityStatus | IssueActivityStatus; 6 | 7 | export interface BotActivity { 8 | id: string; 9 | type: ActivityType; 10 | status: ActivityStatus; 11 | timestamp: string; 12 | url: string; 13 | title: string; 14 | description: string; 15 | prUrl?: string; 16 | } 17 | 18 | export interface DateRange { 19 | start: string; 20 | end: string; 21 | } 22 | 23 | export interface ActivityFilter { 24 | type?: ActivityType | undefined; 25 | status?: ActivityStatus | undefined; 26 | dateRange?: DateRange | undefined; 27 | } 28 | 29 | export interface AppState { 30 | activities: BotActivity[]; 31 | loading: boolean; 32 | error: string | null; 33 | filter: ActivityFilter; 34 | } -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | import { nextui } from "@nextui-org/react"; 3 | import typography from '@tailwindcss/typography'; 4 | 5 | const config: Config = { 6 | content: [ 7 | "./src/**/*.{js,ts,jsx,tsx}", 8 | "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | 'root-primary': '#171717', 14 | 'root-secondary': '#262626', 15 | 'hyperlink': '#007AFF', 16 | 'danger': '#EF3744', 17 | }, 18 | }, 19 | }, 20 | darkMode: "class", 21 | plugins: [ 22 | nextui({ 23 | defaultTheme: "dark", 24 | layout: { 25 | radius: { 26 | small: "5px", 27 | large: "20px", 28 | }, 29 | }, 30 | themes: { 31 | dark: { 32 | colors: { 33 | primary: "#4465DB", 34 | }, 35 | } 36 | } 37 | }), 38 | typography, 39 | ], 40 | }; 41 | 42 | export default config; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 All-Hands-AI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /scripts/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface GitHubComment { 2 | id: number; 3 | body: string; 4 | html_url: string; 5 | created_at: string; 6 | user: { 7 | login: string; 8 | type?: string; 9 | }; 10 | } 11 | 12 | export interface GitHubIssue { 13 | number: number; 14 | title: string; 15 | html_url: string; 16 | comments_url: string; 17 | comments: number; 18 | pull_request?: unknown; 19 | body: string; 20 | } 21 | 22 | export interface GitHubPR { 23 | number: number; 24 | title: string; 25 | html_url: string; 26 | comments_url: string; 27 | comments: number; 28 | body?: string; 29 | } 30 | 31 | export interface GitHubPRResponse { 32 | state: string; 33 | merged: boolean; 34 | } 35 | 36 | export interface ApiResponse { 37 | data: T | T[]; 38 | hasNextPage: boolean; 39 | nextUrl: string | null; 40 | } 41 | 42 | export type IssueStatus = 'no_pr' | 'pr_open' | 'pr_merged' | 'pr_closed'; 43 | export type PRStatus = 'success' | 'failure'; 44 | 45 | export interface Activity { 46 | id: string; 47 | type: 'issue' | 'pr'; 48 | status: IssueStatus | PRStatus; 49 | timestamp: string; 50 | url: string; 51 | title: string; 52 | description: string; 53 | prUrl?: string; 54 | } -------------------------------------------------------------------------------- /scripts/src/cache-github-data.ts: -------------------------------------------------------------------------------- 1 | import { fetchBotActivities } from './github-api'; 2 | import fs from 'fs/promises'; 3 | import path from 'path'; 4 | import type { Activity } from './types'; 5 | 6 | interface CacheData { 7 | activities: Activity[]; 8 | lastUpdated: string; 9 | } 10 | 11 | async function cacheGitHubData(): Promise { 12 | try { 13 | // Fetch all activities for the last 30 days 14 | const activities = await fetchBotActivities(); 15 | 16 | // Create cache directory if it doesn't exist 17 | const rootDir = path.resolve(process.cwd(), '..'); 18 | const cacheDir = path.join(rootDir, 'public', 'cache'); 19 | await fs.mkdir(cacheDir, { recursive: true }); 20 | 21 | // Write activities to cache file 22 | const cacheFile = path.join(cacheDir, 'bot-activities.json'); 23 | const cacheData: CacheData = { 24 | activities, 25 | lastUpdated: new Date().toISOString() 26 | }; 27 | 28 | await fs.writeFile(cacheFile, JSON.stringify(cacheData, null, 2)); 29 | 30 | console.log('Successfully cached GitHub data'); 31 | } catch (error) { 32 | console.error('Error caching GitHub data:', error); 33 | process.exit(1); 34 | } 35 | } 36 | 37 | cacheGitHubData(); -------------------------------------------------------------------------------- /scripts/src/github-api.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | import { isSuccessComment, isPRModificationFailureComment } from './github-api'; 3 | import type { GitHubComment } from './types'; 4 | 5 | describe('Comment detection', () => { 6 | it('should detect issue success comment', () => { 7 | const comment: GitHubComment = { 8 | id: 1, 9 | html_url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-1', 10 | created_at: '2023-11-28T00:01:00Z', 11 | user: { login: 'github-actions[bot]', type: 'Bot' }, 12 | body: 'A potential fix has been generated and a draft PR #5364 has been created. Please review the changes.', 13 | }; 14 | expect(isSuccessComment(comment)).toBe(true); 15 | }); 16 | 17 | it('should detect PR failure comment', () => { 18 | const comment: GitHubComment = { 19 | id: 1, 20 | html_url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-1', 21 | created_at: '2023-11-28T00:01:00Z', 22 | user: { login: 'github-actions[bot]', type: 'Bot' }, 23 | body: 'The workflow to fix this issue encountered an error. Please check the workflow logs for more information.', 24 | }; 25 | expect(isPRModificationFailureComment(comment)).toBe(true); 26 | }); 27 | }); -------------------------------------------------------------------------------- /src/services/github.ts: -------------------------------------------------------------------------------- 1 | import { BotActivity } from '../types'; 2 | 3 | // PR status is now included in the cached data, no need to fetch it 4 | 5 | export async function fetchBotActivities(since?: string): Promise { 6 | try { 7 | const response = await fetch('/cache/bot-activities.json'); 8 | if (!response.ok) { 9 | throw new Error(`Failed to load cached data: ${response.status.toString()} ${response.statusText}`); 10 | } 11 | 12 | const data = await response.json(); 13 | let activities = data.activities as BotActivity[]; 14 | 15 | // Filter by date if since is provided 16 | if (since !== undefined && since !== '') { 17 | const sinceDate = new Date(since).getTime(); 18 | activities = activities.filter(activity => 19 | new Date(activity.timestamp).getTime() >= sinceDate 20 | ); 21 | } 22 | 23 | // PR status is already included in the cached data 24 | const processedActivities = activities; 25 | 26 | // Sort by timestamp in descending order 27 | return processedActivities.sort((a, b) => 28 | new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() 29 | ); 30 | } catch (error) { 31 | console.error('Error fetching bot activities:', error); 32 | throw error; 33 | } 34 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | background-color: #1a1a1a; 46 | cursor: pointer; 47 | transition: border-color 0.25s; 48 | } 49 | button:hover { 50 | border-color: #646cff; 51 | } 52 | button:focus, 53 | button:focus-visible { 54 | outline: 4px auto -webkit-focus-ring-color; 55 | } 56 | 57 | @media (prefers-color-scheme: light) { 58 | :root { 59 | color: #213547; 60 | background-color: #ffffff; 61 | } 62 | a:hover { 63 | color: #747bff; 64 | } 65 | button { 66 | background-color: #f9f9f9; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 6 | "target": "ES2020", 7 | "useDefineForClassFields": true, 8 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 9 | "module": "ESNext", 10 | "skipLibCheck": true, 11 | 12 | /* Bundler mode */ 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "isolatedModules": true, 16 | "moduleDetection": "force", 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedIndexedAccess": true, 25 | "noImplicitReturns": true, 26 | "noImplicitOverride": true, 27 | "noPropertyAccessFromIndexSignature": true, 28 | "exactOptionalPropertyTypes": true, 29 | "noUncheckedSideEffectImports": true, 30 | 31 | /* Type Checking */ 32 | "allowUnreachableCode": false, 33 | "allowUnusedLabels": false, 34 | "noImplicitAny": true, 35 | "strictNullChecks": true, 36 | "strictFunctionTypes": true, 37 | "strictBindCallApply": true, 38 | "strictPropertyInitialization": true, 39 | "noImplicitThis": true, 40 | "useUnknownInCatchVariables": true, 41 | "alwaysStrict": true, 42 | "esModuleInterop": true, 43 | "allowJs": false, 44 | "checkJs": false, 45 | "types": ["vitest/globals"] 46 | }, 47 | "include": ["src", "test", "*.ts"], 48 | "exclude": ["node_modules"] 49 | } 50 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [ 11 | js.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked, 13 | ...tseslint.configs.strictTypeChecked, 14 | ], 15 | files: ['**/*.{ts,tsx}'], 16 | languageOptions: { 17 | ecmaVersion: 2020, 18 | globals: globals.browser, 19 | parserOptions: { 20 | project: ['./tsconfig.app.json', './tsconfig.node.json', './scripts/tsconfig.json'], 21 | tsconfigRootDir: import.meta.dirname, 22 | }, 23 | }, 24 | plugins: { 25 | 'react-hooks': reactHooks, 26 | 'react-refresh': reactRefresh, 27 | }, 28 | rules: { 29 | ...reactHooks.configs.recommended.rules, 30 | 'react-refresh/only-export-components': [ 31 | 'warn', 32 | { allowConstantExport: true }, 33 | ], 34 | '@typescript-eslint/no-explicit-any': 'off', 35 | '@typescript-eslint/explicit-function-return-type': 'off', 36 | '@typescript-eslint/strict-boolean-expressions': 'off', 37 | '@typescript-eslint/no-unnecessary-condition': 'off', 38 | '@typescript-eslint/no-floating-promises': 'off', 39 | '@typescript-eslint/no-misused-promises': 'off', 40 | '@typescript-eslint/await-thenable': 'off', 41 | '@typescript-eslint/no-unsafe-assignment': 'off', 42 | '@typescript-eslint/no-unsafe-member-access': 'off', 43 | '@typescript-eslint/no-unsafe-call': 'off', 44 | '@typescript-eslint/no-unsafe-return': 'off', 45 | }, 46 | }, 47 | ) -------------------------------------------------------------------------------- /src/test/testUtils.ts: -------------------------------------------------------------------------------- 1 | export function getComputedStyle(element: HTMLElement, property: string): string { 2 | return window.getComputedStyle(element).getPropertyValue(property); 3 | } 4 | 5 | export function getCSSVariable(variableName: string): string { 6 | const style = getComputedStyle(document.documentElement, '--' + variableName); 7 | return style.trim(); 8 | } 9 | 10 | // Helper to check if an element has dark theme compatible colors 11 | export function hasDarkThemeColors(element: HTMLElement): boolean { 12 | const bgColor = getComputedStyle(element, 'background-color'); 13 | const color = getComputedStyle(element, 'color'); 14 | 15 | // Convert color names and hex to rgb 16 | const colorToRgb = (color: string): number[] => { 17 | const div = document.createElement('div'); 18 | div.style.color = color; 19 | document.body.appendChild(div); 20 | const computed = window.getComputedStyle(div).color; 21 | document.body.removeChild(div); 22 | return computed.match(/\d+/g)?.map(Number) || []; 23 | }; 24 | 25 | // Convert rgb/rgba to brightness value (0-255) 26 | const getBrightness = (color: string): number => { 27 | const rgb = colorToRgb(color); 28 | if (rgb.length < 3) return 0; 29 | // Perceived brightness formula 30 | return (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000; 31 | }; 32 | 33 | const bgBrightness = getBrightness(bgColor); 34 | const textBrightness = getBrightness(color); 35 | 36 | // For dark theme: 37 | // - Background should be dark (brightness < 128) 38 | // - Text should be light (brightness > 128) 39 | // - There should be sufficient contrast between them 40 | return ( 41 | bgBrightness < 128 && 42 | textBrightness > 128 && 43 | Math.abs(textBrightness - bgBrightness) > 50 44 | ); 45 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [ 11 | js.configs.recommended, 12 | ...tseslint.configs.recommendedTypeChecked, 13 | ...tseslint.configs.strictTypeChecked, 14 | ], 15 | files: ['**/*.{ts,tsx}'], 16 | languageOptions: { 17 | ecmaVersion: 2020, 18 | globals: globals.browser, 19 | parserOptions: { 20 | project: ['./tsconfig.app.json', './tsconfig.node.json', './scripts/tsconfig.json', './tsconfig.json'], 21 | tsconfigRootDir: import.meta.dirname, 22 | }, 23 | }, 24 | plugins: { 25 | 'react-hooks': reactHooks, 26 | 'react-refresh': reactRefresh, 27 | }, 28 | rules: { 29 | ...reactHooks.configs.recommended.rules, 30 | 'react-refresh/only-export-components': [ 31 | 'warn', 32 | { allowConstantExport: true }, 33 | ], 34 | '@typescript-eslint/no-explicit-any': 'off', 35 | '@typescript-eslint/explicit-function-return-type': 'off', 36 | '@typescript-eslint/strict-boolean-expressions': 'off', 37 | '@typescript-eslint/no-unnecessary-condition': 'off', 38 | '@typescript-eslint/no-floating-promises': 'off', 39 | '@typescript-eslint/no-misused-promises': 'off', 40 | '@typescript-eslint/await-thenable': 'off', 41 | '@typescript-eslint/no-unsafe-assignment': 'off', 42 | '@typescript-eslint/no-unsafe-member-access': 'off', 43 | '@typescript-eslint/no-unsafe-call': 'off', 44 | '@typescript-eslint/no-unsafe-return': 'off', 45 | }, 46 | }, 47 | ) -------------------------------------------------------------------------------- /src/components/ActivityFilter.tsx: -------------------------------------------------------------------------------- 1 | import { ActivityFilter as FilterType, ActivityStatus, ActivityType } from '../types'; 2 | 3 | interface ActivityFilterProps { 4 | filter: FilterType; 5 | onFilterChange: (filter: FilterType) => void; 6 | } 7 | 8 | export function ActivityFilter({ filter, onFilterChange }: ActivityFilterProps): React.JSX.Element { 9 | const handleTypeChange = (type: ActivityType | ''): void => { 10 | onFilterChange({ 11 | ...filter, 12 | type: type === '' ? undefined : type, 13 | }); 14 | }; 15 | 16 | const handleStatusChange = (status: ActivityStatus | ''): void => { 17 | onFilterChange({ 18 | ...filter, 19 | status: status === '' ? undefined : status, 20 | }); 21 | }; 22 | 23 | const handleTypeSelect = (e: React.ChangeEvent): void => { 24 | handleTypeChange(e.target.value as ActivityType | ''); 25 | }; 26 | 27 | const handleStatusSelect = (e: React.ChangeEvent): void => { 28 | handleStatusChange(e.target.value as ActivityStatus | ''); 29 | }; 30 | 31 | return ( 32 |
33 |
34 | 35 | 44 |
45 | 46 |
47 | 48 | 57 |
58 |
59 | ); 60 | } -------------------------------------------------------------------------------- /src/components/ActivityFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/react'; 2 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 | import { ActivityFilter } from './ActivityFilter'; 4 | import { ActivityFilter as FilterType } from '../types'; 5 | 6 | describe('ActivityFilter', () => { 7 | const mockFilter: FilterType = {}; 8 | const mockOnFilterChange = vi.fn(); 9 | 10 | beforeEach(() => { 11 | mockOnFilterChange.mockClear(); 12 | }); 13 | 14 | it('renders filter options correctly', () => { 15 | render(); 16 | 17 | expect(screen.getByLabelText('Type:')).toBeInTheDocument(); 18 | expect(screen.getByLabelText('Status:')).toBeInTheDocument(); 19 | }); 20 | 21 | it('handles type filter changes', () => { 22 | render(); 23 | 24 | const typeSelect = screen.getByLabelText('Type:'); 25 | fireEvent.change(typeSelect, { target: { value: 'issue' } }); 26 | 27 | expect(mockOnFilterChange).toHaveBeenCalledWith({ 28 | ...mockFilter, 29 | type: 'issue', 30 | }); 31 | }); 32 | 33 | it('handles status filter changes', () => { 34 | render(); 35 | 36 | const statusSelect = screen.getByLabelText('Status:'); 37 | fireEvent.change(statusSelect, { target: { value: 'success' } }); 38 | 39 | expect(mockOnFilterChange).toHaveBeenCalledWith({ 40 | ...mockFilter, 41 | status: 'success', 42 | }); 43 | }); 44 | 45 | it('clears filters when selecting "All"', () => { 46 | const initialFilter: FilterType = { 47 | type: 'issue', 48 | status: 'success', 49 | }; 50 | 51 | render(); 52 | 53 | const typeSelect = screen.getByLabelText('Type:'); 54 | fireEvent.change(typeSelect, { target: { value: '' } }); 55 | 56 | expect(mockOnFilterChange).toHaveBeenCalledWith({ 57 | ...initialFilter, 58 | type: undefined, 59 | }); 60 | }); 61 | }); -------------------------------------------------------------------------------- /scripts/src/monitor-cache.ts: -------------------------------------------------------------------------------- 1 | interface CacheStatus { 2 | lastSuccessfulUpdate?: string; 3 | lastAttempt?: string; 4 | status: 'success' | 'error'; 5 | activitiesCount?: number; 6 | error?: string; 7 | } 8 | 9 | async function checkCacheStatus(): Promise { 10 | try { 11 | const fs = await import('fs/promises'); 12 | const path = await import('path'); 13 | 14 | const statusPath = path.join(process.cwd(), 'public/cache/status.json'); 15 | const cacheDataPath = path.join(process.cwd(), 'public/cache/bot-activities.json'); 16 | 17 | // Read status file 18 | const statusData = JSON.parse(await fs.readFile(statusPath, 'utf8')) as CacheStatus; 19 | 20 | // Read cache file 21 | const cacheData = JSON.parse(await fs.readFile(cacheDataPath, 'utf8')); 22 | 23 | // Check cache freshness 24 | const lastUpdate = new Date(statusData.lastSuccessfulUpdate || 0); 25 | const now = new Date(); 26 | const hoursSinceUpdate = (now.getTime() - lastUpdate.getTime()) / (1000 * 60 * 60); 27 | 28 | console.log('Cache Status Report:'); 29 | console.log('-------------------'); 30 | console.log(`Status: ${statusData.status}`); 31 | console.log(`Last Successful Update: ${statusData.lastSuccessfulUpdate || 'Never'}`); 32 | console.log(`Hours Since Last Update: ${String(hoursSinceUpdate.toFixed(2))}`); 33 | console.log(`Activities in Cache: ${String(cacheData.activities?.length || 0)}`); 34 | 35 | if (statusData.error) { 36 | console.error('Last Error:', statusData.error); 37 | } 38 | 39 | // Alert if cache is stale 40 | if (hoursSinceUpdate > 12) { 41 | throw new Error(`Cache is stale! Last update was ${String(hoursSinceUpdate.toFixed(2))} hours ago`); 42 | } 43 | 44 | // Alert if no activities 45 | if (!cacheData.activities?.length) { 46 | throw new Error('Cache contains no activities!'); 47 | } 48 | 49 | console.log('\nCache status is healthy ✓'); 50 | 51 | } catch (error) { 52 | console.error('\nCache Health Check Failed!'); 53 | console.error(error instanceof Error ? error.message : String(error)); 54 | process.exit(1); 55 | } 56 | } 57 | 58 | // Run the check if this file is being run directly 59 | if (require.main === module) { 60 | checkCacheStatus(); 61 | } -------------------------------------------------------------------------------- /src/components/ActivityList.tsx: -------------------------------------------------------------------------------- 1 | import { BotActivity } from '../types'; 2 | import { useState } from 'react'; 3 | 4 | interface ActivityListProps { 5 | activities: BotActivity[]; 6 | } 7 | 8 | export function ActivityList({ activities }: ActivityListProps): React.JSX.Element { 9 | const [currentPage, setCurrentPage] = useState(1); 10 | const itemsPerPage = 20; 11 | const totalPages = Math.ceil(activities.length / itemsPerPage); 12 | 13 | const startIndex = (currentPage - 1) * itemsPerPage; 14 | const endIndex = startIndex + itemsPerPage; 15 | const currentActivities = activities.slice(startIndex, endIndex); 16 | 17 | const handlePageChange = (page: number) => { 18 | setCurrentPage(page); 19 | }; 20 | 21 | return ( 22 |
23 |
24 | {currentActivities.map((activity) => ( 25 |
26 |
27 | {activity.title} 28 |
29 |
{activity.description}
30 |
31 | 32 | View on GitHub 33 | 34 | {activity.prUrl && ( 35 | <> 36 | {' | '} 37 | 38 | View PR 39 | 40 | 41 | )} 42 |
43 |
44 | ))} 45 |
46 | {totalPages > 1 && ( 47 |
48 | 54 | 55 | Page {currentPage} of {totalPages} 56 | 57 | 63 |
64 | )} 65 |
66 | ); 67 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "temp", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build --mode production", 9 | "build:cache": "echo 'Building cache...' && cd scripts && echo 'Installing dependencies...' && npm install && echo 'Compiling TypeScript...' && npx tsc && echo 'Generating GitHub data cache...' && node dist/cache-github-data.js && echo 'Cache generation complete'", 10 | "build:all": "npm run build:cache && npm run build", 11 | "lint": "eslint .", 12 | "lint:fix": "eslint . --fix", 13 | "preview": "vite preview", 14 | "test": "vitest run --exclude '**/__integration_tests__/**'", 15 | "test:watch": "vitest --exclude '**/__integration_tests__/**'", 16 | "test:coverage": "vitest run --coverage --exclude '**/__integration_tests__/**'", 17 | "test:integration": "vitest run '**/__integration_tests__/**'", 18 | "typecheck": "tsc --noEmit", 19 | "cache-data": "node scripts/cache-github-data.js", 20 | "monitor:cache": "cd scripts && npx tsc && node dist/monitor-cache.js", 21 | "build:cache:safe": "npm run build:cache || (echo 'Cache build failed, checking status...' && npm run monitor:cache)" 22 | }, 23 | "dependencies": { 24 | "@nextui-org/react": "^2.4.8", 25 | "@tailwindcss/typography": "^0.5.15", 26 | "autoprefixer": "^10.4.20", 27 | "postcss": "^8.4.49", 28 | "react": "^18.3.1", 29 | "react-dom": "^18.3.1", 30 | "tailwindcss": "^3.4.15" 31 | }, 32 | "devDependencies": { 33 | "@eslint/js": "^9.15.0", 34 | "@testing-library/jest-dom": "^6.6.3", 35 | "@testing-library/react": "^16.0.1", 36 | "@types/node": "^22.10.1", 37 | "@types/node-fetch": "^2.6.12", 38 | "@types/react": "^18.3.12", 39 | "@types/react-dom": "^18.3.1", 40 | "@typescript-eslint/eslint-plugin": "^8.16.0", 41 | "@typescript-eslint/parser": "^8.16.0", 42 | "@vitejs/plugin-react": "^4.3.4", 43 | "@vitest/coverage-v8": "^2.1.8", 44 | "eslint": "^9.15.0", 45 | "eslint-plugin-react-hooks": "^5.0.0", 46 | "eslint-plugin-react-refresh": "^0.4.14", 47 | "globals": "^15.12.0", 48 | "jsdom": "^25.0.1", 49 | "react-vega": "^7.6.0", 50 | "ts-node": "^10.9.2", 51 | "typescript": "~5.6.2", 52 | "typescript-eslint": "^8.15.0", 53 | "vega-lite": "^5.21.0", 54 | "vite": "^6.0.1", 55 | "vitest": "^2.1.8" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/components/DateRangeFilter.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { DateRange } from '../types'; 3 | 4 | interface DateRangeFilterProps { 5 | dateRange?: DateRange | undefined; 6 | onDateRangeChange: (dateRange?: DateRange) => void; 7 | } 8 | 9 | export function DateRangeFilter({ dateRange, onDateRangeChange }: DateRangeFilterProps): React.JSX.Element { 10 | const getDefaultDateRange = () => { 11 | const end = new Date(); 12 | const start = new Date(); 13 | start.setDate(start.getDate() - 7); 14 | return { 15 | start: start.toISOString().split('T')[0], 16 | end: end.toISOString().split('T')[0] 17 | }; 18 | }; 19 | 20 | const defaultRange = getDefaultDateRange(); 21 | const [start, setStart] = useState(dateRange?.start ?? defaultRange.start); 22 | const [end, setEnd] = useState(dateRange?.end ?? defaultRange.end); 23 | 24 | useEffect(() => { 25 | if (dateRange === undefined) { 26 | const defaultRange = getDefaultDateRange(); 27 | setStart(defaultRange.start); 28 | setEnd(defaultRange.end); 29 | onDateRangeChange(defaultRange); 30 | } else { 31 | setStart(dateRange.start); 32 | setEnd(dateRange.end); 33 | } 34 | }, [dateRange, onDateRangeChange]); 35 | 36 | const handleStartDateChange = (event: React.ChangeEvent): void => { 37 | const newStart = event.target.value; 38 | setStart(newStart); 39 | 40 | if (newStart === '' && end === '') { 41 | onDateRangeChange(undefined); 42 | } else { 43 | onDateRangeChange({ start: newStart, end }); 44 | } 45 | }; 46 | 47 | const handleEndDateChange = (event: React.ChangeEvent): void => { 48 | const newEnd = event.target.value; 49 | setEnd(newEnd); 50 | 51 | if (start === '' && newEnd === '') { 52 | onDateRangeChange(undefined); 53 | } else { 54 | onDateRangeChange({ start, end: newEnd }); 55 | } 56 | }; 57 | 58 | const maxDate = new Date().toISOString().split('T')[0]; 59 | 60 | return ( 61 |
62 |
63 | 64 | 71 |
72 | 73 |
74 | 75 | 82 |
83 |
84 | ); 85 | } -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | // Mock CSS Modules 4 | const cssModule = new Proxy( 5 | {}, 6 | { 7 | get: () => 'mock-css-module' 8 | } 9 | ); 10 | 11 | // Mock CSS files 12 | const cssFile = ` 13 | :root { 14 | --bg-dark: #0c0e10; 15 | --bg-light: #292929; 16 | --bg-input: #393939; 17 | --bg-workspace: #1f2228; 18 | --border: #3c3c4a; 19 | --text-editor-base: #9099AC; 20 | --text-editor-active: #C4CBDA; 21 | --bg-editor-sidebar: #24272E; 22 | --bg-editor-active: #31343D; 23 | --border-editor-sidebar: #3C3C4A; 24 | --bg-neutral-muted: #afb8c133; 25 | } 26 | 27 | .filter-group select, 28 | .filter-group input { 29 | padding: 0.5rem; 30 | border: 1px solid var(--border); 31 | border-radius: 4px; 32 | font-size: 1rem; 33 | background-color: var(--bg-input) !important; 34 | color: var(--text-editor-active) !important; 35 | min-width: 120px; 36 | } 37 | 38 | .filter-group input[type="date"]::-webkit-calendar-picker-indicator { 39 | filter: invert(1); 40 | cursor: pointer; 41 | } 42 | 43 | .filter-group { 44 | display: flex; 45 | align-items: center; 46 | gap: 0.5rem; 47 | margin: 0.5rem; 48 | } 49 | 50 | .pagination { 51 | display: flex; 52 | justify-content: center; 53 | align-items: center; 54 | gap: 1rem; 55 | margin-top: 2rem; 56 | } 57 | 58 | .pagination button { 59 | padding: 0.5rem 1rem; 60 | background-color: var(--bg-input) !important; 61 | color: var(--text-editor-active) !important; 62 | border: 1px solid var(--border); 63 | border-radius: 4px; 64 | cursor: pointer; 65 | transition: background-color 0.2s; 66 | } 67 | 68 | .pagination button:disabled { 69 | opacity: 0.5; 70 | cursor: not-allowed; 71 | } 72 | 73 | .pagination button:not(:disabled):hover { 74 | background: var(--bg-editor-active); 75 | } 76 | 77 | .pagination .page-info { 78 | color: var(--text-editor-base) !important; 79 | padding: 0.5rem 1rem; 80 | } 81 | `; 82 | 83 | // Create a style element and append it to the document head 84 | const style = document.createElement('style'); 85 | style.textContent = cssFile; 86 | document.head.appendChild(style); 87 | 88 | // Add pseudo-element styles 89 | const pseudoStyles = document.createElement('style'); 90 | pseudoStyles.textContent = ` 91 | .filter-group input[type="date"]::-webkit-calendar-picker-indicator { 92 | filter: invert(1); 93 | cursor: pointer; 94 | } 95 | 96 | .pagination button:not(:disabled):hover { 97 | background-color: var(--bg-editor-active) !important; 98 | } 99 | `; 100 | document.head.appendChild(pseudoStyles); 101 | 102 | // Mock CSS imports 103 | vi.mock('*.css', () => cssModule); 104 | vi.mock('*.scss', () => cssModule); 105 | vi.mock('*.sass', () => cssModule); 106 | vi.mock('*.less', () => cssModule); -------------------------------------------------------------------------------- /src/components/ActivityPieChart.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { VegaLite } from 'react-vega'; 3 | 4 | import { BotActivity, ActivityType } from '../types'; 5 | 6 | interface ActivityPieChartProps { 7 | activities: BotActivity[]; 8 | type: ActivityType; 9 | } 10 | 11 | interface ChartData { 12 | status: string; 13 | count: number; 14 | } 15 | 16 | type ChartSpec = { 17 | $schema: string; 18 | data: { values: ChartData[] }; 19 | mark: { type: 'arc'; innerRadius: number }; 20 | encoding: { 21 | theta: { 22 | field: 'count'; 23 | type: 'quantitative'; 24 | }; 25 | color: { 26 | field: 'status'; 27 | type: 'nominal'; 28 | title: 'Status'; 29 | scale?: { 30 | domain: string[]; 31 | range: string[]; 32 | }; 33 | legend?: { 34 | labelColor: string; 35 | titleColor: string; 36 | }; 37 | }; 38 | }; 39 | width: number; 40 | height: number; 41 | title: string | { text: string; color: string }; 42 | background?: string; 43 | config?: { 44 | view: { 45 | stroke: string; 46 | }; 47 | }; 48 | } 49 | 50 | export function ActivityPieChart({ activities, type }: ActivityPieChartProps): React.JSX.Element { 51 | const chartData = useMemo((): ChartData[] => { 52 | const filteredActivities = activities.filter(a => a.type === type); 53 | const statusCounts = new Map(); 54 | 55 | filteredActivities.forEach(activity => { 56 | const count = statusCounts.get(activity.status) || 0; 57 | statusCounts.set(activity.status, count + 1); 58 | }); 59 | 60 | return Array.from(statusCounts.entries()).map(([status, count]) => ({ 61 | status, 62 | count, 63 | })); 64 | }, [activities, type]); 65 | 66 | const spec: ChartSpec = { 67 | $schema: 'https://vega.github.io/schema/vega-lite/v5.json', 68 | data: { values: chartData }, 69 | mark: { type: 'arc', innerRadius: 50 }, 70 | encoding: { 71 | theta: { 72 | field: 'count', 73 | type: 'quantitative' 74 | }, 75 | color: { 76 | field: 'status', 77 | type: 'nominal', 78 | title: 'Status', 79 | scale: type === 'issue' ? { 80 | domain: ['no_pr', 'pr_open', 'pr_merged', 'pr_closed'], 81 | range: ['#ffffff', '#4caf50', '#9c27b0', '#f44336'] 82 | } : { 83 | domain: ['success', 'failure'], 84 | range: ['#22c55e', '#ef4444'] 85 | }, 86 | legend: { 87 | labelColor: '#C4CBDA', 88 | titleColor: '#C4CBDA' 89 | } 90 | }, 91 | }, 92 | width: 300, 93 | height: 300, 94 | background: '#1f2228', 95 | title: { 96 | text: `Total ${type.toUpperCase()} Status Distribution`, 97 | color: '#C4CBDA' 98 | }, 99 | config: { 100 | view: { 101 | stroke: 'transparent' 102 | } 103 | } 104 | }; 105 | 106 | return ; 107 | } -------------------------------------------------------------------------------- /api/cron.js: -------------------------------------------------------------------------------- 1 | import { fetchBotActivities } from '../scripts/src/github-api'; 2 | 3 | export default async function handler(req, res) { 4 | console.log('Cron job started:', new Date().toISOString()); 5 | 6 | try { 7 | if (req.method !== 'GET') { 8 | console.log('Invalid method:', req.method); 9 | return res.status(405).json({ error: 'Method not allowed' }); 10 | } 11 | 12 | // Check authorization for GET requests 13 | const { authorization } = req.headers; 14 | if (authorization !== `Bearer ${process.env.CRON_SECRET}`) { 15 | console.warn('Unauthorized access attempt'); 16 | return res.status(401).json({ error: 'Unauthorized' }); 17 | } 18 | 19 | console.log('Fetching bot activities...'); 20 | const activities = await fetchBotActivities(); 21 | 22 | // Store the cache in public/cache directory 23 | const cacheData = { 24 | activities, 25 | lastUpdated: new Date().toISOString() 26 | }; 27 | 28 | // Write to a status file to track last successful update 29 | const statusData = { 30 | lastSuccessfulUpdate: new Date().toISOString(), 31 | activitiesCount: activities.length, 32 | status: 'success' 33 | }; 34 | 35 | try { 36 | const { writeFile } = await import('fs/promises'); 37 | const { join } = await import('path'); 38 | 39 | // Ensure directories exist 40 | const { mkdir } = await import('fs/promises'); 41 | await mkdir('public/cache', { recursive: true }); 42 | 43 | // Write cache and status files 44 | await writeFile( 45 | join('public/cache/bot-activities.json'), 46 | JSON.stringify(cacheData, null, 2) 47 | ); 48 | 49 | await writeFile( 50 | join('public/cache/status.json'), 51 | JSON.stringify(statusData, null, 2) 52 | ); 53 | 54 | console.log('Cache files written successfully'); 55 | } catch (fsError) { 56 | console.error('Failed to write cache files:', fsError); 57 | throw new Error('Failed to write cache files: ' + fsError.message); 58 | } 59 | 60 | console.log('Cron job completed successfully'); 61 | return res.status(200).json({ 62 | success: true, 63 | timestamp: new Date().toISOString(), 64 | activitiesCount: activities.length 65 | }); 66 | } catch (error) { 67 | console.error('Cron job failed:', error); 68 | 69 | // Write error status 70 | try { 71 | const { writeFile } = await import('fs/promises'); 72 | const { join } = await import('path'); 73 | 74 | await writeFile( 75 | join('public/cache/status.json'), 76 | JSON.stringify({ 77 | lastAttempt: new Date().toISOString(), 78 | status: 'error', 79 | error: error.message 80 | }, null, 2) 81 | ); 82 | } catch (fsError) { 83 | console.error('Failed to write error status:', fsError); 84 | } 85 | 86 | return res.status(500).json({ 87 | error: error.message, 88 | timestamp: new Date().toISOString(), 89 | stack: process.env.NODE_ENV === 'development' ? error.stack : undefined 90 | }); 91 | } 92 | } -------------------------------------------------------------------------------- /test/services/github-api.test.ts: -------------------------------------------------------------------------------- 1 | import { isSuccessComment, isFailureComment } from '../../scripts/src/github-api'; 2 | import type { GitHubComment } from '../../scripts/src/types'; 3 | 4 | describe('GitHub API Comment Detection', () => { 5 | const createBotComment = (body: string, type: string = 'Bot'): GitHubComment => ({ 6 | id: 1, 7 | body, 8 | html_url: 'https://github.com/example/comment', 9 | created_at: '2024-12-03T00:00:00Z', 10 | user: { 11 | login: 'github-actions[bot]', 12 | type 13 | } 14 | }); 15 | 16 | describe('isSuccessComment', () => { 17 | it('should detect PR creation success comments', () => { 18 | const comment = createBotComment('A potential fix has been generated and a draft PR #5364 has been created. Please review the changes.'); 19 | expect(isSuccessComment(comment)).toBe(true); 20 | }); 21 | 22 | it('should detect changes made success comments', () => { 23 | const comment = createBotComment('OpenHands made the following changes to resolve the issues'); 24 | expect(isSuccessComment(comment)).toBe(true); 25 | }); 26 | 27 | it('should detect successfully fixed comments', () => { 28 | const comment = createBotComment('Successfully fixed the issue'); 29 | expect(isSuccessComment(comment)).toBe(true); 30 | }); 31 | 32 | it('should detect updated PR comments', () => { 33 | const comment = createBotComment('Updated pull request with the latest changes'); 34 | expect(isSuccessComment(comment)).toBe(true); 35 | }); 36 | 37 | it('should not detect non-success comments', () => { 38 | const comment = createBotComment('Started fixing the issue'); 39 | expect(isSuccessComment(comment)).toBe(false); 40 | }); 41 | 42 | it('should not detect non-bot comments', () => { 43 | const comment = createBotComment('A potential fix has been generated', 'User'); 44 | expect(isSuccessComment(comment)).toBe(false); 45 | }); 46 | }); 47 | 48 | describe('isFailureComment', () => { 49 | it('should detect error comments', () => { 50 | const comment = createBotComment('The workflow to fix this issue encountered an error'); 51 | expect(isFailureComment(comment)).toBe(true); 52 | }); 53 | 54 | it('should detect failed to create changes comments', () => { 55 | const comment = createBotComment('OpenHands failed to create any code changes'); 56 | expect(isFailureComment(comment)).toBe(true); 57 | }); 58 | 59 | it('should detect unsuccessful fix comments', () => { 60 | const comment = createBotComment('An attempt was made to automatically fix this issue, but it was unsuccessful'); 61 | expect(isFailureComment(comment)).toBe(true); 62 | }); 63 | 64 | it('should not detect non-failure comments', () => { 65 | const comment = createBotComment('Started fixing the issue'); 66 | expect(isFailureComment(comment)).toBe(false); 67 | }); 68 | 69 | it('should not detect non-bot comments', () => { 70 | const comment = createBotComment('The workflow encountered an error', 'User'); 71 | expect(isFailureComment(comment)).toBe(false); 72 | }); 73 | }); 74 | }); -------------------------------------------------------------------------------- /src/components/ActivityChart.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { VegaLite } from 'react-vega'; 3 | 4 | import { BotActivity, ActivityType } from '../types'; 5 | 6 | interface ActivityChartProps { 7 | activities: BotActivity[]; 8 | type: ActivityType; 9 | } 10 | 11 | interface ChartData { 12 | date: string; 13 | status: string; 14 | } 15 | 16 | type ChartSpec = { 17 | $schema: string; 18 | data: { values: ChartData[] }; 19 | mark: { type: 'bar' }; 20 | encoding: { 21 | x: { 22 | field: 'date'; 23 | type: 'temporal'; 24 | title: 'Date'; 25 | axis?: { 26 | labelColor: string; 27 | titleColor: string; 28 | gridColor: string; 29 | domainColor: string; 30 | }; 31 | }; 32 | y: { 33 | aggregate: 'count'; 34 | type: 'quantitative'; 35 | title: 'Count'; 36 | axis?: { 37 | labelColor: string; 38 | titleColor: string; 39 | gridColor: string; 40 | domainColor: string; 41 | }; 42 | }; 43 | color: { 44 | field: 'status'; 45 | type: 'nominal'; 46 | title: 'Status'; 47 | scale?: { 48 | domain: string[]; 49 | range: string[]; 50 | }; 51 | legend?: { 52 | labelColor: string; 53 | titleColor: string; 54 | }; 55 | }; 56 | }; 57 | width: number; 58 | height: number; 59 | title: string | { text: string; color: string }; 60 | background?: string; 61 | config?: { 62 | view: { 63 | stroke: string; 64 | }; 65 | }; 66 | } 67 | 68 | export function ActivityChart({ activities, type }: ActivityChartProps): React.JSX.Element { 69 | const chartData = useMemo((): ChartData[] => { 70 | return activities 71 | .filter(a => a.type === type) 72 | .map(a => { 73 | const [date] = a.timestamp.split('T'); 74 | if (date === undefined || date === '') { 75 | throw new Error('Invalid timestamp format'); 76 | } 77 | return { 78 | date, 79 | status: a.status, 80 | }; 81 | }); 82 | }, [activities, type]); 83 | 84 | const spec: ChartSpec = { 85 | $schema: 'https://vega.github.io/schema/vega-lite/v5.json', 86 | data: { values: chartData }, 87 | mark: { type: 'bar' }, 88 | encoding: { 89 | x: { 90 | field: 'date', 91 | type: 'temporal', 92 | title: 'Date', 93 | axis: { 94 | labelColor: '#C4CBDA', 95 | titleColor: '#C4CBDA', 96 | gridColor: '#3c3c4a', 97 | domainColor: '#3c3c4a' 98 | } 99 | }, 100 | y: { 101 | aggregate: 'count', 102 | type: 'quantitative', 103 | title: 'Count', 104 | axis: { 105 | labelColor: '#C4CBDA', 106 | titleColor: '#C4CBDA', 107 | gridColor: '#3c3c4a', 108 | domainColor: '#3c3c4a' 109 | } 110 | }, 111 | color: { 112 | field: 'status', 113 | type: 'nominal', 114 | title: 'Status', 115 | scale: type === 'issue' ? { 116 | domain: ['no_pr', 'pr_open', 'pr_merged', 'pr_closed'], 117 | range: ['#ffffff', '#4caf50', '#9c27b0', '#f44336'] // White, Green, Purple, Red 118 | } : { 119 | domain: ['success', 'failure'], 120 | range: ['#22c55e', '#ef4444'] // Green for success, Red for failure 121 | }, 122 | legend: { 123 | labelColor: '#C4CBDA', 124 | titleColor: '#C4CBDA' 125 | } 126 | }, 127 | }, 128 | width: 400, 129 | height: 300, 130 | background: '#1f2228', 131 | title: { 132 | text: `${type.toUpperCase()} Activity Over Time`, 133 | color: '#C4CBDA' 134 | }, 135 | config: { 136 | view: { 137 | stroke: 'transparent' 138 | } 139 | } 140 | }; 141 | 142 | return ; 143 | } -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/__integration_tests__/github.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Integration tests for the GitHub service. 3 | * 4 | * These tests interact with the real GitHub API and require authentication. 5 | * They are not meant to be run in CI, but rather locally or in a controlled environment. 6 | * 7 | * Prerequisites: 8 | * - A valid GitHub token with repo scope must be set in VITE_GITHUB_TOKEN environment variable 9 | * - The token must have access to the All-Hands-AI/OpenHands repository 10 | * 11 | * To run these tests: 12 | * 1. Create a .env file in the project root with: 13 | * VITE_GITHUB_TOKEN=your_github_token 14 | * 2. Run the integration tests: 15 | * npm run test:integration 16 | * 17 | * Note: These tests may take longer to run due to API rate limits and network latency. 18 | * They also depend on the actual state of the repository, so results may vary. 19 | */ 20 | 21 | import { describe, it, expect, vi } from 'vitest'; 22 | import { fetchBotActivities } from '../services/github'; 23 | import type { BotActivity } from '../types'; 24 | import * as fs from 'fs'; 25 | 26 | describe('GitHub Service Integration Tests', () => { 27 | // Skip these tests if VITE_GITHUB_TOKEN is not set 28 | const runTest = import.meta.env['VITE_GITHUB_TOKEN'] !== undefined && import.meta.env['VITE_GITHUB_TOKEN'] !== '' ? it : it.skip; 29 | 30 | runTest('should fetch real bot activities from OpenHands repository', async () => { 31 | // Mock the fetch function to return the actual cached data 32 | const cachedData = JSON.parse(fs.readFileSync('/workspace/openhands-agent-monitor/public/cache/bot-activities.json', 'utf8')) as BotActivity[]; 33 | const mockFetch = vi.fn().mockImplementation((url: string) => { 34 | if (url === '/cache/bot-activities.json') { 35 | return Promise.resolve({ 36 | ok: true, 37 | status: 200, 38 | statusText: 'OK', 39 | json: () => Promise.resolve(cachedData), 40 | headers: new Headers() 41 | } as Response); 42 | } 43 | throw new Error(`Unexpected URL: ${url}`); 44 | }); 45 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 46 | 47 | const activities = await fetchBotActivities(); 48 | 49 | // Verify we got some activities 50 | expect(activities.length).toBeGreaterThan(0); 51 | 52 | // Verify each activity has the required fields 53 | const expectedFields: Record = { 54 | id: expect.any(String), 55 | type: expect.stringMatching(/^(issue|pr)$/), 56 | status: expect.stringMatching(/^(success|failure)$/), 57 | timestamp: expect.any(String), 58 | url: expect.stringMatching(/^https:\/\/github\.com\//), 59 | title: expect.any(String), 60 | description: expect.any(String), 61 | prUrl: expect.any(String), 62 | }; 63 | 64 | for (const activity of activities) { 65 | expect(activity).toMatchObject(expectedFields); 66 | 67 | // Verify the timestamp is a valid date 68 | expect(new Date(activity.timestamp).toString()).not.toBe('Invalid Date'); 69 | 70 | // Verify the URL points to the correct repository 71 | expect(activity.url).toContain('All-Hands-AI/OpenHands'); 72 | } 73 | 74 | // Log some stats to help with debugging 75 | const issueCount = activities.filter(a => a.type === 'issue').length; 76 | const prCount = activities.filter(a => a.type === 'pr').length; 77 | const successCount = activities.filter(a => a.status === 'success').length; 78 | const failureCount = activities.filter(a => a.status === 'failure').length; 79 | 80 | console.log(`Found ${activities.length.toString()} activities:`); 81 | console.log(`- Issues: ${issueCount.toString()}`); 82 | console.log(`- PRs: ${prCount.toString()}`); 83 | console.log(`- Successes: ${successCount.toString()}`); 84 | console.log(`- Failures: ${failureCount.toString()}`); 85 | 86 | // Print the first activity for manual verification 87 | if (activities.length > 0) { 88 | console.log('\nFirst activity:', JSON.stringify(activities[0], null, 2)); 89 | } 90 | }, 30000); // Increase timeout to 30s for API calls 91 | }); 92 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | import { ActivityList } from './components/ActivityList'; 3 | import { ActivityFilter } from './components/ActivityFilter'; 4 | import { DateRangeFilter } from './components/DateRangeFilter'; 5 | import { ActivityChart } from './components/ActivityChart'; 6 | import { ActivityPieChart } from './components/ActivityPieChart'; 7 | import { LoadingSpinner } from './components/LoadingSpinner'; 8 | import { ErrorMessage } from './components/ErrorMessage'; 9 | import { ActivityFilter as FilterType, DateRange, AppState } from './types'; 10 | import { fetchBotActivities } from './services/github'; 11 | import './App.css'; 12 | 13 | function App(): React.JSX.Element { 14 | const [state, setState] = useState({ 15 | activities: [], 16 | loading: true, 17 | error: null, 18 | filter: {} 19 | }); 20 | 21 | const loadActivities = useCallback(async (): Promise => { 22 | setState(prev => ({ ...prev, loading: true, error: null })); 23 | try { 24 | const since = state.filter.dateRange?.start; 25 | const activities = await fetchBotActivities(since); 26 | setState(prev => ({ ...prev, activities, loading: false })); 27 | } catch (error) { 28 | setState(prev => ({ 29 | ...prev, 30 | loading: false, 31 | error: error instanceof Error ? error.message : 'An error occurred while fetching activities', 32 | })); 33 | } 34 | }, [state.filter.dateRange?.start]); 35 | 36 | useEffect(() => { 37 | void loadActivities(); 38 | }, [loadActivities]); 39 | 40 | const handleFilterChange = (filter: FilterType): void => { 41 | setState(prev => ({ ...prev, filter })); 42 | }; 43 | 44 | const handleDateRangeChange = (dateRange?: DateRange): void => { 45 | setState(prev => ({ 46 | ...prev, 47 | filter: { 48 | ...prev.filter, 49 | dateRange, 50 | }, 51 | })); 52 | }; 53 | 54 | const handleRetry = (): void => { 55 | void loadActivities(); 56 | }; 57 | 58 | const filteredActivities = state.activities.filter((activity) => { 59 | if (state.filter.type && activity.type !== state.filter.type) return false; 60 | if (state.filter.status && activity.status !== state.filter.status) return false; 61 | if (state.filter.dateRange && state.filter.dateRange.start && state.filter.dateRange.end) { 62 | const activityDate = new Date(activity.timestamp); 63 | const startDate = new Date(state.filter.dateRange.start); 64 | const endDate = new Date(state.filter.dateRange.end); 65 | if (activityDate < startDate || activityDate > endDate) return false; 66 | } 67 | return true; 68 | }); 69 | 70 | return ( 71 |
72 |
73 | OpenHands Logo 74 |

OpenHands Agent Activity Monitor

75 |
76 | 77 |
78 |

Filters

79 | 83 | 87 |
88 | 89 | {state.loading ? ( 90 | 91 | ) : state.error !== null ? ( 92 | 96 | ) : ( 97 | <> 98 |
99 |

Activity Charts

100 |
101 | 102 | 103 |
104 |
105 | 106 | 107 |
108 |
109 | 110 |
111 |

Activity List

112 | 113 |
114 | 115 | )} 116 |
117 | ); 118 | } 119 | 120 | export default App; 121 | -------------------------------------------------------------------------------- /src/components/ActivityPieChart.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | import { ActivityPieChart } from './ActivityPieChart'; 4 | import { BotActivity } from '../types'; 5 | 6 | // Mock VegaLite component and capture the spec prop 7 | interface VegaLiteProps { 8 | spec: { 9 | data: { values: any[] }; 10 | encoding: { 11 | theta: { field: string; type: string }; 12 | color: { scale: { domain: string[]; range: string[] } }; 13 | }; 14 | title: { text: string; color: string }; 15 | }; 16 | } 17 | 18 | const mockVegaLite = vi.fn(); 19 | vi.mock('react-vega', () => ({ 20 | VegaLite: (props: VegaLiteProps) => { 21 | mockVegaLite(props); 22 | return
; 23 | }, 24 | })); 25 | 26 | function getLastVegaLiteProps(): VegaLiteProps { 27 | expect(mockVegaLite).toHaveBeenCalled(); 28 | const lastCall = mockVegaLite.mock.calls[mockVegaLite.mock.calls.length - 1][0]; 29 | expect(lastCall).toBeDefined(); 30 | return lastCall; 31 | } 32 | 33 | describe('ActivityPieChart', () => { 34 | const mockActivities: BotActivity[] = [ 35 | { 36 | id: '1', 37 | type: 'issue', 38 | status: 'no_pr', 39 | timestamp: '2023-11-28T12:00:00Z', 40 | url: 'https://github.com/example/1', 41 | title: 'Test Issue 1', 42 | description: 'Issue without PR', 43 | }, 44 | { 45 | id: '2', 46 | type: 'issue', 47 | status: 'pr_open', 48 | timestamp: '2023-11-28T13:00:00Z', 49 | url: 'https://github.com/example/2', 50 | title: 'Test Issue 2', 51 | description: 'Issue with open PR', 52 | prUrl: 'https://github.com/example/pr/2', 53 | }, 54 | { 55 | id: '3', 56 | type: 'issue', 57 | status: 'pr_merged', 58 | timestamp: '2023-11-28T14:00:00Z', 59 | url: 'https://github.com/example/3', 60 | title: 'Test Issue 3', 61 | description: 'Issue with merged PR', 62 | prUrl: 'https://github.com/example/pr/3', 63 | }, 64 | { 65 | id: '4', 66 | type: 'issue', 67 | status: 'pr_closed', 68 | timestamp: '2023-11-28T15:00:00Z', 69 | url: 'https://github.com/example/4', 70 | title: 'Test Issue 4', 71 | description: 'Issue with closed PR', 72 | prUrl: 'https://github.com/example/pr/4', 73 | }, 74 | { 75 | id: '5', 76 | type: 'pr', 77 | status: 'success', 78 | timestamp: '2023-11-28T16:00:00Z', 79 | url: 'https://github.com/example/5', 80 | title: 'Test PR 1', 81 | description: 'Successful PR', 82 | }, 83 | { 84 | id: '6', 85 | type: 'pr', 86 | status: 'failure', 87 | timestamp: '2023-11-28T17:00:00Z', 88 | url: 'https://github.com/example/6', 89 | title: 'Test PR 2', 90 | description: 'Failed PR', 91 | }, 92 | ]; 93 | 94 | it('renders pie chart component', () => { 95 | const { getByTestId } = render( 96 | 97 | ); 98 | 99 | expect(getByTestId('vega-lite-pie-chart')).toBeInTheDocument(); 100 | }); 101 | 102 | it('aggregates data correctly for issues', () => { 103 | render(); 104 | const lastCall = getLastVegaLiteProps(); 105 | const chartData = lastCall.spec.data.values; 106 | 107 | expect(chartData).toHaveLength(4); 108 | expect(chartData).toEqual(expect.arrayContaining([ 109 | { status: 'no_pr', count: 1 }, 110 | { status: 'pr_open', count: 1 }, 111 | { status: 'pr_merged', count: 1 }, 112 | { status: 'pr_closed', count: 1 } 113 | ])); 114 | }); 115 | 116 | it('aggregates data correctly for PRs', () => { 117 | render(); 118 | const lastCall = getLastVegaLiteProps(); 119 | const chartData = lastCall.spec.data.values; 120 | 121 | expect(chartData).toHaveLength(2); 122 | expect(chartData).toEqual(expect.arrayContaining([ 123 | { status: 'success', count: 1 }, 124 | { status: 'failure', count: 1 } 125 | ])); 126 | }); 127 | 128 | it('configures issue color scale correctly', () => { 129 | render(); 130 | const lastCall = getLastVegaLiteProps(); 131 | const colorScale = lastCall.spec.encoding.color.scale; 132 | 133 | expect(colorScale.domain).toEqual(['no_pr', 'pr_open', 'pr_merged', 'pr_closed']); 134 | expect(colorScale.range).toEqual(['#ffffff', '#4caf50', '#9c27b0', '#f44336']); 135 | }); 136 | 137 | it('configures PR color scale correctly', () => { 138 | render(); 139 | const lastCall = getLastVegaLiteProps(); 140 | const colorScale = lastCall.spec.encoding.color.scale; 141 | 142 | expect(colorScale.domain).toEqual(['success', 'failure']); 143 | expect(colorScale.range).toEqual(['#22c55e', '#ef4444']); 144 | }); 145 | 146 | it('configures chart title correctly', () => { 147 | render(); 148 | const lastCall = getLastVegaLiteProps(); 149 | const { title } = lastCall.spec; 150 | 151 | expect(title).toEqual({ 152 | text: 'Total ISSUE Status Distribution', 153 | color: '#C4CBDA' 154 | }); 155 | }); 156 | }); -------------------------------------------------------------------------------- /src/components/ActivityChart.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { describe, it, expect, vi } from 'vitest'; 3 | import { ActivityChart } from './ActivityChart'; 4 | import { BotActivity } from '../types'; 5 | 6 | // Mock VegaLite component and capture the spec prop 7 | interface VegaLiteProps { 8 | spec: { 9 | data: { values: any[] }; 10 | encoding: { 11 | color: { scale: { domain: string[]; range: string[] } }; 12 | x: any; 13 | y: any; 14 | }; 15 | title: { text: string; color: string }; 16 | }; 17 | } 18 | 19 | const mockVegaLite = vi.fn(); 20 | vi.mock('react-vega', () => ({ 21 | VegaLite: (props: VegaLiteProps) => { 22 | mockVegaLite(props); 23 | return
; 24 | }, 25 | })); 26 | 27 | function getLastVegaLiteProps(): VegaLiteProps { 28 | expect(mockVegaLite).toHaveBeenCalled(); 29 | const lastCall = mockVegaLite.mock.calls[mockVegaLite.mock.calls.length - 1][0]; 30 | expect(lastCall).toBeDefined(); 31 | return lastCall; 32 | } 33 | 34 | describe('ActivityChart', () => { 35 | const mockActivities: BotActivity[] = [ 36 | { 37 | id: '1', 38 | type: 'issue', 39 | status: 'no_pr', 40 | timestamp: '2023-11-28T12:00:00Z', 41 | url: 'https://github.com/example/1', 42 | title: 'Test Issue 1', 43 | description: 'Issue without PR', 44 | }, 45 | { 46 | id: '2', 47 | type: 'issue', 48 | status: 'pr_open', 49 | timestamp: '2023-11-28T13:00:00Z', 50 | url: 'https://github.com/example/2', 51 | title: 'Test Issue 2', 52 | description: 'Issue with open PR', 53 | prUrl: 'https://github.com/example/pr/2', 54 | }, 55 | { 56 | id: '3', 57 | type: 'issue', 58 | status: 'pr_merged', 59 | timestamp: '2023-11-28T14:00:00Z', 60 | url: 'https://github.com/example/3', 61 | title: 'Test Issue 3', 62 | description: 'Issue with merged PR', 63 | prUrl: 'https://github.com/example/pr/3', 64 | }, 65 | { 66 | id: '4', 67 | type: 'issue', 68 | status: 'pr_closed', 69 | timestamp: '2023-11-28T15:00:00Z', 70 | url: 'https://github.com/example/4', 71 | title: 'Test Issue 4', 72 | description: 'Issue with closed PR', 73 | prUrl: 'https://github.com/example/pr/4', 74 | }, 75 | { 76 | id: '5', 77 | type: 'pr', 78 | status: 'success', 79 | timestamp: '2023-11-28T16:00:00Z', 80 | url: 'https://github.com/example/5', 81 | title: 'Test PR 1', 82 | description: 'Successful PR', 83 | }, 84 | { 85 | id: '6', 86 | type: 'pr', 87 | status: 'failure', 88 | timestamp: '2023-11-28T17:00:00Z', 89 | url: 'https://github.com/example/6', 90 | title: 'Test PR 2', 91 | description: 'Failed PR', 92 | }, 93 | ]; 94 | 95 | it('renders chart component', () => { 96 | const { getByTestId } = render( 97 | 98 | ); 99 | 100 | expect(getByTestId('vega-lite-chart')).toBeInTheDocument(); 101 | }); 102 | 103 | it('filters activities by type', () => { 104 | render(); 105 | const lastCall = getLastVegaLiteProps(); 106 | const chartData = lastCall.spec.data.values; 107 | 108 | // Should only include issue activities 109 | expect(chartData).toHaveLength(4); 110 | expect(chartData.every((d: { date: string; status: string }) => 111 | ['no_pr', 'pr_open', 'pr_merged', 'pr_closed'].includes(d.status) 112 | )).toBe(true); 113 | }); 114 | 115 | it('configures issue color scale correctly', () => { 116 | render(); 117 | const lastCall = getLastVegaLiteProps(); 118 | const colorScale = lastCall.spec.encoding.color.scale; 119 | 120 | expect(colorScale.domain).toEqual(['no_pr', 'pr_open', 'pr_merged', 'pr_closed']); 121 | expect(colorScale.range).toEqual(['#ffffff', '#4caf50', '#9c27b0', '#f44336']); 122 | }); 123 | 124 | it('configures PR color scale correctly', () => { 125 | render(); 126 | const lastCall = getLastVegaLiteProps(); 127 | const colorScale = lastCall.spec.encoding.color.scale; 128 | 129 | expect(colorScale.domain).toEqual(['success', 'failure']); 130 | expect(colorScale.range).toEqual(['#22c55e', '#ef4444']); 131 | }); 132 | 133 | it('configures chart axes correctly', () => { 134 | render(); 135 | const lastCall = getLastVegaLiteProps(); 136 | const { x, y } = lastCall.spec.encoding; 137 | 138 | expect(x.field).toBe('date'); 139 | expect(x.type).toBe('temporal'); 140 | expect(x.title).toBe('Date'); 141 | expect(x.axis).toEqual({ 142 | labelColor: '#C4CBDA', 143 | titleColor: '#C4CBDA', 144 | gridColor: '#3c3c4a', 145 | domainColor: '#3c3c4a' 146 | }); 147 | 148 | expect(y.aggregate).toBe('count'); 149 | expect(y.type).toBe('quantitative'); 150 | expect(y.title).toBe('Count'); 151 | expect(y.axis).toEqual({ 152 | labelColor: '#C4CBDA', 153 | titleColor: '#C4CBDA', 154 | gridColor: '#3c3c4a', 155 | domainColor: '#3c3c4a' 156 | }); 157 | }); 158 | 159 | it('configures chart title correctly', () => { 160 | render(); 161 | const lastCall = getLastVegaLiteProps(); 162 | const { title } = lastCall.spec; 163 | 164 | expect(title).toEqual({ 165 | text: 'ISSUE Activity Over Time', 166 | color: '#C4CBDA' 167 | }); 168 | }); 169 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenHands Bot Activity Monitor 2 | 3 | A web application to monitor and visualize the activity of the OpenHands GitHub bot. 4 | 5 | ## Features 6 | 7 | ### 1. Activity Listing 8 | - Lists all attempted issue resolutions and PR modifications 9 | - Entries are sorted by date 10 | - Each entry shows: 11 | - Type (Issue/PR) 12 | - Status (Success/Failure) 13 | - Timestamp 14 | - Link to the relevant GitHub item 15 | - Description of the action taken 16 | 17 | ### 2. Filtering Capabilities 18 | - Filter activities by: 19 | - Type (Issue resolution or PR modification) 20 | - Status (Success or Failure) 21 | - Date range 22 | 23 | ### 3. Visualization 24 | - Stacked line charts implemented using Vega-Lite showing: 25 | - Issue resolutions over time (successes vs failures) 26 | - PR modifications over time (successes vs failures) 27 | - Time-based trends in bot activity 28 | 29 | ## Prerequisites 30 | 31 | - Node.js 18 or later 32 | - npm 9 or later 33 | - A GitHub Personal Access Token with the following permissions: 34 | - `repo` scope for accessing repository data 35 | - `read:org` scope if monitoring repositories in an organization 36 | 37 | ## Environment Variables 38 | 39 | The application uses environment variables for configuration: 40 | 41 | ### Build Time 42 | - `GITHUB_TOKEN` - GitHub Personal Access Token (required for cache generation) 43 | - Required scopes: `repo` for repository access, `read:org` for organization repositories 44 | - Only needed during cache generation, not at runtime 45 | 46 | You can set this in a `.env` file for local development: 47 | ```env 48 | GITHUB_TOKEN=your_github_token_here 49 | ``` 50 | 51 | Note: Never commit your `.env` file to version control. The `.gitignore` file already includes it. 52 | 53 | ## Installation 54 | 55 | 1. Clone the repository: 56 | ```bash 57 | git clone https://github.com/All-Hands-AI/openhands-agent-monitor.git 58 | cd openhands-agent-monitor 59 | ``` 60 | 61 | 2. Install dependencies: 62 | ```bash 63 | npm install 64 | ``` 65 | 66 | ## Development 67 | 68 | 1. Generate the data cache: 69 | ```bash 70 | # Make sure GITHUB_TOKEN is set in your .env file 71 | npm run build:cache 72 | ``` 73 | 74 | 2. Start the development server: 75 | ```bash 76 | npm run dev 77 | ``` 78 | 79 | 3. Open your browser and navigate to `http://localhost:5173` 80 | 81 | Note: The cache is static during development. Run `npm run build:cache` again to refresh the data. 82 | 83 | ## Testing 84 | 85 | Run the test suite: 86 | ```bash 87 | # Run tests in watch mode 88 | npm run test 89 | 90 | # Run tests with coverage report 91 | npm run test:coverage 92 | ``` 93 | 94 | ## Building and Deployment 95 | 96 | The application uses a caching mechanism to improve performance and security. Instead of making GitHub API calls from the frontend, the data is pre-fetched and cached during the build process. 97 | 98 | ### Build Commands 99 | 100 | - `npm run build` - Build the frontend application only 101 | - `npm run build:cache` - Generate the GitHub data cache 102 | - `npm run build:all` - Generate cache and build the application 103 | 104 | ### Deployment Process 105 | 106 | 1. Set up environment: 107 | ```bash 108 | # Clone repository and install dependencies 109 | git clone https://github.com/All-Hands-AI/openhands-agent-monitor.git 110 | cd openhands-agent-monitor 111 | npm install 112 | ``` 113 | 114 | 2. Generate cache and build: 115 | ```bash 116 | # Set GitHub token for cache generation 117 | export GITHUB_TOKEN=your_github_token_here 118 | 119 | # Generate cache and build application 120 | npm run build:all 121 | ``` 122 | 123 | 3. Deploy the `dist` directory to your hosting provider. 124 | 125 | Note: The GitHub token is only needed during the build process to generate the cache. The frontend application reads from this cache and does not need the token at runtime. 126 | 127 | ### Static Hosting (e.g., GitHub Pages, Netlify) 128 | 129 | 1. Set up deployment: 130 | ```bash 131 | # Add build command in your hosting provider 132 | npm run build 133 | 134 | # Add cache generation to your build pipeline 135 | npm run build:cache 136 | ``` 137 | 138 | 2. Configure environment variables: 139 | - Set `GITHUB_TOKEN` in your CI/CD environment 140 | - No environment variables needed in the hosting environment 141 | 142 | ### Docker Deployment 143 | 144 | 1. Build the Docker image: 145 | ```bash 146 | # Build with cache generation 147 | docker build --build-arg GITHUB_TOKEN=your_token_here -t openhands-monitor . 148 | ``` 149 | 150 | 2. Run the container: 151 | ```bash 152 | # No token needed at runtime 153 | docker run -p 8080:80 openhands-monitor 154 | ``` 155 | 156 | The app will be available at `http://localhost:8080`. 157 | 158 | ## Configuration 159 | 160 | The application is configured to monitor the OpenHands repository by default. To monitor a different repository, modify the following constants in `src/services/github.ts`: 161 | 162 | ```typescript 163 | const REPO_OWNER = 'your-org-name'; 164 | const REPO_NAME = 'your-repo-name'; 165 | ``` 166 | 167 | ## Troubleshooting 168 | 169 | ### Common Issues 170 | 171 | 1. **Cache Generation Fails** 172 | - Symptom: Error during `npm run build:cache` 173 | - Solution: 174 | - Verify `GITHUB_TOKEN` is set and has correct permissions 175 | - Check for GitHub API rate limiting 176 | - Ensure repository configuration is correct 177 | 178 | 2. **No Data Showing** 179 | - Symptom: Empty activity list 180 | - Solution: 181 | - Verify cache was generated successfully 182 | - Check date range filter settings 183 | - Run `npm run build:cache` to refresh data 184 | 185 | 3. **Development Server Shows Old Data** 186 | - Symptom: Changes in GitHub not reflected 187 | - Solution: Run `npm run build:cache` to update the cache 188 | 189 | ### Getting Help 190 | 191 | If you encounter issues: 192 | 193 | 1. Check the browser console for error messages 194 | 2. Verify environment variables are set correctly 195 | 3. Ensure GitHub token has required permissions 196 | 4. Open an issue in the repository with: 197 | - Description of the problem 198 | - Steps to reproduce 199 | - Error messages (if any) 200 | - Environment details (OS, browser, Node.js version) 201 | 202 | ## Contributing 203 | 204 | 1. Fork the repository 205 | 2. Create a feature branch 206 | 3. Make your changes 207 | 4. Write or update tests 208 | 5. Submit a pull request 209 | 210 | ## License 211 | 212 | This project is licensed under the MIT License - see the LICENSE file for details. 213 | ``` 214 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg-dark: #0c0e10; 3 | --bg-light: #292929; 4 | --bg-input: #393939; 5 | --bg-workspace: #1f2228; 6 | --border: #3c3c4a; 7 | --text-editor-base: #9099AC; 8 | --text-editor-active: #C4CBDA; 9 | --bg-editor-sidebar: #24272E; 10 | --bg-editor-active: #31343D; 11 | --border-editor-sidebar: #3C3C4A; 12 | --bg-neutral-muted: #afb8c133; 13 | } 14 | 15 | body { 16 | background-color: var(--bg-dark); 17 | color: var(--text-editor-active); 18 | } 19 | 20 | .app { 21 | max-width: 1200px; 22 | margin: 0 auto; 23 | padding: 1rem; 24 | } 25 | 26 | .app-header { 27 | display: flex; 28 | align-items: center; 29 | gap: 1rem; 30 | margin-bottom: 2rem; 31 | flex-wrap: wrap; 32 | justify-content: center; 33 | } 34 | 35 | .app-logo { 36 | height: 40px; 37 | width: auto; 38 | } 39 | 40 | h1 { 41 | text-align: center; 42 | margin-bottom: 1rem; 43 | color: var(--text-editor-active); 44 | font-size: clamp(1.5rem, 4vw, 2rem); 45 | } 46 | 47 | @media (max-width: 768px) { 48 | .app { 49 | padding: 0.5rem; 50 | } 51 | 52 | .app-header { 53 | flex-direction: column; 54 | text-align: center; 55 | } 56 | } 57 | 58 | section { 59 | margin-bottom: 3rem; 60 | } 61 | 62 | .filters { 63 | background: var(--bg-workspace); 64 | padding: 1.5rem; 65 | border-radius: 8px; 66 | border: 1px solid var(--border); 67 | } 68 | 69 | .activity-filter { 70 | display: flex; 71 | gap: 1rem; 72 | justify-content: center; 73 | margin-bottom: 1rem; 74 | flex-wrap: wrap; 75 | } 76 | 77 | .date-range-filter { 78 | display: flex; 79 | gap: 1rem; 80 | justify-content: center; 81 | flex-wrap: wrap; 82 | } 83 | 84 | .filter-group { 85 | display: flex; 86 | align-items: center; 87 | gap: 0.5rem; 88 | margin: 0.5rem; 89 | } 90 | 91 | .filter-group select, 92 | .filter-group input { 93 | padding: 0.5rem; 94 | border: 1px solid var(--border); 95 | border-radius: 4px; 96 | font-size: 1rem; 97 | background: var(--bg-input); 98 | color: var(--text-editor-active); 99 | min-width: 120px; 100 | } 101 | 102 | .filter-group input[type="date"]::-webkit-calendar-picker-indicator { 103 | filter: invert(1); 104 | cursor: pointer; 105 | } 106 | 107 | /* Calendar popup styling */ 108 | input[type="date"]::-webkit-datetime-edit, 109 | input[type="date"]::-webkit-inner-spin-button, 110 | input[type="date"]::-webkit-clear-button { 111 | color: var(--text-editor-active); 112 | } 113 | 114 | input[type="date"]::-webkit-calendar-picker { 115 | background-color: var(--bg-workspace); 116 | color: var(--text-editor-active); 117 | border: 1px solid var(--border); 118 | border-radius: 4px; 119 | } 120 | 121 | @media (max-width: 768px) { 122 | .activity-filter, 123 | .date-range-filter { 124 | flex-direction: column; 125 | align-items: stretch; 126 | gap: 0.5rem; 127 | } 128 | 129 | .filter-group { 130 | margin: 0.25rem 0; 131 | justify-content: space-between; 132 | } 133 | 134 | .filter-group select, 135 | .filter-group input { 136 | flex: 1; 137 | margin-left: 0.5rem; 138 | } 139 | } 140 | 141 | .chart-container { 142 | display: flex; 143 | flex-wrap: wrap; 144 | gap: 2rem; 145 | justify-content: center; 146 | background: var(--bg-workspace); 147 | padding: 1rem; 148 | border-radius: 8px; 149 | border: 1px solid var(--border); 150 | overflow-x: auto; 151 | } 152 | 153 | .activity-list { 154 | display: flex; 155 | flex-direction: column; 156 | gap: 1rem; 157 | } 158 | 159 | .activity-item { 160 | border: 1px solid var(--border); 161 | border-radius: 8px; 162 | padding: 1rem; 163 | background: var(--bg-workspace); 164 | word-break: break-word; 165 | } 166 | 167 | .activity-item.success { 168 | border-left: 4px solid #4caf50; 169 | } 170 | 171 | .activity-item.failure { 172 | border-left: 4px solid var(--danger); 173 | } 174 | 175 | /* Issue-specific status colors */ 176 | .activity-item.no_pr { 177 | border-left: 4px solid #ffffff; 178 | } 179 | 180 | .activity-item.pr_open { 181 | border-left: 4px solid #4caf50; 182 | } 183 | 184 | .activity-item.pr_merged { 185 | border-left: 4px solid #9c27b0; 186 | } 187 | 188 | .activity-item.pr_closed { 189 | border-left: 4px solid #f44336; 190 | } 191 | 192 | .activity-header { 193 | display: flex; 194 | gap: 1rem; 195 | margin-bottom: 0.5rem; 196 | flex-wrap: wrap; 197 | } 198 | 199 | @media (max-width: 768px) { 200 | .chart-container { 201 | padding: 0.5rem; 202 | gap: 1rem; 203 | } 204 | 205 | .activity-item { 206 | padding: 0.75rem; 207 | } 208 | 209 | .activity-header { 210 | gap: 0.5rem; 211 | } 212 | } 213 | 214 | .activity-type { 215 | font-weight: bold; 216 | color: var(--text-editor-active); 217 | } 218 | 219 | .activity-status { 220 | text-transform: capitalize; 221 | } 222 | 223 | .activity-time { 224 | color: var(--text-editor-base); 225 | } 226 | 227 | .activity-description { 228 | margin: 0.5rem 0; 229 | color: var(--text-editor-base); 230 | } 231 | 232 | .activity-item a { 233 | color: var(--hyperlink); 234 | text-decoration: none; 235 | } 236 | 237 | .activity-item a:hover { 238 | text-decoration: underline; 239 | } 240 | 241 | .loading-spinner { 242 | display: flex; 243 | flex-direction: column; 244 | align-items: center; 245 | justify-content: center; 246 | padding: 2rem; 247 | } 248 | 249 | .spinner { 250 | width: 50px; 251 | height: 50px; 252 | border: 5px solid var(--bg-input); 253 | border-top: 5px solid var(--hyperlink); 254 | border-radius: 50%; 255 | animation: spin 1s linear infinite; 256 | margin-bottom: 1rem; 257 | } 258 | 259 | @keyframes spin { 260 | 0% { transform: rotate(0deg); } 261 | 100% { transform: rotate(360deg); } 262 | } 263 | 264 | .error-message { 265 | text-align: center; 266 | padding: 2rem; 267 | background: var(--bg-workspace); 268 | border: 1px solid var(--danger); 269 | border-radius: 8px; 270 | margin: 1rem 0; 271 | } 272 | 273 | .error-message p { 274 | color: var(--danger); 275 | margin-bottom: 1rem; 276 | } 277 | 278 | .error-message button { 279 | background: var(--hyperlink); 280 | color: white; 281 | border: none; 282 | padding: 0.5rem 1rem; 283 | border-radius: 4px; 284 | cursor: pointer; 285 | font-size: 1rem; 286 | } 287 | 288 | .error-message button:hover { 289 | opacity: 0.9; 290 | } 291 | 292 | /* Pagination styles */ 293 | .pagination { 294 | display: flex; 295 | justify-content: center; 296 | align-items: center; 297 | gap: 1rem; 298 | margin-top: 2rem; 299 | } 300 | 301 | .pagination button { 302 | padding: 0.5rem 1rem; 303 | background: var(--bg-input); 304 | color: var(--text-editor-active); 305 | border: 1px solid var(--border); 306 | border-radius: 4px; 307 | cursor: pointer; 308 | transition: background-color 0.2s; 309 | } 310 | 311 | .pagination button:disabled { 312 | opacity: 0.5; 313 | cursor: not-allowed; 314 | } 315 | 316 | .pagination button:not(:disabled):hover { 317 | background: var(--bg-editor-active); 318 | } 319 | 320 | .pagination .page-info { 321 | color: var(--text-editor-base); 322 | padding: 0.5rem 1rem; 323 | } 324 | -------------------------------------------------------------------------------- /src/components/DateRangeFilter.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent, act } from '@testing-library/react'; 2 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 | import { DateRangeFilter } from './DateRangeFilter'; 4 | import { DateRange } from '../types'; 5 | import { getComputedStyle } from '../test/testUtils'; 6 | 7 | describe('DateRangeFilter', () => { 8 | const mockDateRange: DateRange = { 9 | start: '2023-11-01', 10 | end: '2023-11-30', 11 | }; 12 | const mockOnDateRangeChange = vi.fn(); 13 | 14 | beforeEach(() => { 15 | mockOnDateRangeChange.mockClear(); 16 | }); 17 | 18 | it('renders date inputs with provided values', () => { 19 | render( 20 | 24 | ); 25 | 26 | const startInput = screen.getByLabelText('From:'); 27 | const endInput = screen.getByLabelText('To:'); 28 | 29 | expect(startInput.value).toBe('2023-11-01'); 30 | expect(endInput.value).toBe('2023-11-30'); 31 | }); 32 | 33 | it('handles start date changes', () => { 34 | render( 35 | 39 | ); 40 | 41 | const startInput = screen.getByLabelText('From:'); 42 | fireEvent.change(startInput, { target: { value: '2023-11-15' } }); 43 | 44 | expect(mockOnDateRangeChange).toHaveBeenCalledWith({ 45 | start: '2023-11-15', 46 | end: '2023-11-30', 47 | }); 48 | }); 49 | 50 | it('handles end date changes', () => { 51 | render( 52 | 56 | ); 57 | 58 | const endInput = screen.getByLabelText('To:'); 59 | fireEvent.change(endInput, { target: { value: '2023-11-20' } }); 60 | 61 | expect(mockOnDateRangeChange).toHaveBeenCalledWith({ 62 | start: '2023-11-01', 63 | end: '2023-11-20', 64 | }); 65 | }); 66 | 67 | it('handles clearing dates', () => { 68 | render( 69 | 73 | ); 74 | 75 | const startInput = screen.getByLabelText('From:'); 76 | const endInput = screen.getByLabelText('To:'); 77 | 78 | // Clear both inputs 79 | fireEvent.change(startInput, { target: { value: '' } }); 80 | fireEvent.change(endInput, { target: { value: '' } }); 81 | expect(mockOnDateRangeChange).toHaveBeenLastCalledWith(undefined); 82 | }); 83 | 84 | it('initializes with default 7-day range when no dateRange is provided', () => { 85 | vi.useFakeTimers(); 86 | const now = new Date('2024-01-15T12:00:00Z'); 87 | vi.setSystemTime(now); 88 | 89 | const mockOnDateRangeChange = vi.fn(); 90 | 91 | act(() => { 92 | render( 93 | 97 | ); 98 | }); 99 | 100 | const startInput = screen.getByLabelText('From:'); 101 | const endInput = screen.getByLabelText('To:'); 102 | 103 | // Check that inputs show 7-day range 104 | expect(startInput.value).toBe('2024-01-08'); 105 | expect(endInput.value).toBe('2024-01-15'); 106 | 107 | // Check that onDateRangeChange was called with default range 108 | expect(mockOnDateRangeChange).toHaveBeenCalledWith({ 109 | start: '2024-01-08', 110 | end: '2024-01-15' 111 | }); 112 | 113 | vi.useRealTimers(); 114 | }); 115 | 116 | it('maintains provided dateRange over default range', () => { 117 | const customDateRange = { 118 | start: '2024-01-01', 119 | end: '2024-01-31' 120 | }; 121 | 122 | render( 123 | 127 | ); 128 | 129 | const startInput = screen.getByLabelText('From:'); 130 | const endInput = screen.getByLabelText('To:'); 131 | 132 | expect(startInput.value).toBe('2024-01-01'); 133 | expect(endInput.value).toBe('2024-01-31'); 134 | expect(mockOnDateRangeChange).not.toHaveBeenCalled(); 135 | }); 136 | 137 | describe('dark theme styling', () => { 138 | beforeEach(() => { 139 | // Set up CSS variables for dark theme 140 | document.documentElement.style.setProperty('--bg-input', '#393939'); 141 | document.documentElement.style.setProperty('--text-editor-active', '#C4CBDA'); 142 | document.documentElement.style.setProperty('--border', '#3c3c4a'); 143 | }); 144 | 145 | it('applies dark theme styles to date inputs', () => { 146 | render( 147 | 151 | ); 152 | 153 | // Check if the style rules exist with correct variables 154 | const styleRules = Array.from(document.styleSheets) 155 | .flatMap(sheet => Array.from(sheet.cssRules)) 156 | .map(rule => rule.cssText) 157 | .join('\n'); 158 | 159 | expect(styleRules).toContain('.filter-group input'); 160 | expect(styleRules).toContain('var(--bg-input)'); 161 | expect(styleRules).toContain('var(--text-editor-active)'); 162 | expect(styleRules).toContain('var(--border)'); 163 | }); 164 | 165 | it('has proper spacing between filter groups', () => { 166 | render( 167 | 171 | ); 172 | 173 | const filterGroups = document.querySelectorAll('.filter-group'); 174 | const firstGroup = filterGroups[0] as HTMLElement; 175 | 176 | // Check gap between filter groups 177 | expect(getComputedStyle(firstGroup, 'gap')).toBe('0.5rem'); 178 | expect(getComputedStyle(firstGroup, 'margin')).toBe('0.5rem'); 179 | }); 180 | 181 | it('has proper input padding and border radius', () => { 182 | render( 183 | 187 | ); 188 | 189 | const startInput = screen.getByLabelText('From:'); 190 | 191 | expect(getComputedStyle(startInput, 'padding')).toBe('0.5rem'); 192 | expect(getComputedStyle(startInput, 'border-radius')).toBe('4px'); 193 | }); 194 | 195 | it('calendar picker indicator is visible in dark mode', () => { 196 | render( 197 | 201 | ); 202 | 203 | screen.getByLabelText('From:'); 204 | 205 | // Check if the calendar picker indicator style exists 206 | const styleRules = Array.from(document.styleSheets) 207 | .flatMap(sheet => Array.from(sheet.cssRules)) 208 | .map(rule => rule.cssText) 209 | .join('\n'); 210 | 211 | expect(styleRules).toContain('::-webkit-calendar-picker-indicator'); 212 | expect(styleRules).toContain('filter: invert(1)'); 213 | }); 214 | }); 215 | }); -------------------------------------------------------------------------------- /src/services/github.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Unit tests for the GitHub service. 3 | * 4 | * These tests use mocked API responses to verify the service's behavior 5 | * without making actual network requests. They focus on testing: 6 | * 7 | * 1. Comment detection logic for both issues and PRs 8 | * 2. Success/failure status determination 9 | * 3. Activity data structure and formatting 10 | * 11 | * The tests use Vitest's mocking capabilities to simulate GitHub API responses. 12 | */ 13 | 14 | import { describe, it, expect, beforeEach, vi } from 'vitest'; 15 | import { fetchBotActivities } from './github'; 16 | import type { BotActivity } from '../types'; 17 | 18 | describe('GitHub Service', () => { 19 | beforeEach(() => { 20 | // Mock the fetch function 21 | vi.stubGlobal('fetch', vi.fn()); 22 | }); 23 | 24 | function createMockResponse(data: unknown): Promise { 25 | return Promise.resolve({ 26 | ok: true, 27 | status: 200, 28 | statusText: 'OK', 29 | json: () => Promise.resolve(data), 30 | headers: new Headers() 31 | } as Response); 32 | } 33 | 34 | it('should detect openhands-agent comments in issues with PR', async () => { 35 | // Mock cache response for issue success 36 | const mockFetch = vi.fn().mockImplementation((url: string) => { 37 | if (url === '/cache/bot-activities.json') { 38 | return createMockResponse({ 39 | activities: [{ 40 | id: 'issue-1-2', 41 | type: 'issue', 42 | status: 'pr_open', 43 | timestamp: '2023-11-28T00:01:00Z', 44 | url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-2', 45 | prUrl: 'https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/2', 46 | description: 'A potential fix has been generated and a draft PR #2 has been created. Please review the changes.' 47 | }], 48 | lastUpdated: '2023-11-28T00:01:00Z' 49 | }); 50 | } 51 | throw new Error(`Unexpected URL: ${url}`); 52 | }); 53 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 54 | 55 | const activities = await fetchBotActivities(); 56 | 57 | expect(activities).toHaveLength(1); 58 | expect(activities[0]).toMatchObject>({ 59 | type: 'issue', 60 | status: 'pr_open', 61 | id: expect.stringContaining('issue-1') as string, 62 | }); 63 | 64 | // Restore the original fetch 65 | vi.unstubAllGlobals(); 66 | }); 67 | 68 | it('should detect openhands-agent failure comments in issues without PR', async () => { 69 | // Mock cache response for issue failure 70 | const mockFetch = vi.fn().mockImplementation((url: string) => { 71 | if (url === '/cache/bot-activities.json') { 72 | return createMockResponse({ 73 | activities: [{ 74 | id: 'issue-1-2', 75 | type: 'issue', 76 | status: 'no_pr', 77 | timestamp: '2023-11-28T00:01:00Z', 78 | url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-2', 79 | description: 'The workflow to fix this issue encountered an error. Openhands failed to create any code changes.' 80 | }], 81 | lastUpdated: '2023-11-28T00:01:00Z' 82 | }); 83 | } 84 | throw new Error(`Unexpected URL: ${url}`); 85 | }); 86 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 87 | 88 | const activities = await fetchBotActivities(); 89 | 90 | expect(activities).toHaveLength(1); 91 | expect(activities[0]).toMatchObject>({ 92 | type: 'issue', 93 | status: 'no_pr', 94 | id: expect.stringContaining('issue-1') as string, 95 | }); 96 | 97 | // Restore the original fetch 98 | vi.unstubAllGlobals(); 99 | }); 100 | 101 | it('should detect openhands-agent failure comments in PRs', async () => { 102 | // Mock cache response for PR failure 103 | const mockFetch = vi.fn().mockImplementation((url: string) => { 104 | if (url === '/cache/bot-activities.json') { 105 | return createMockResponse({ 106 | activities: [{ 107 | id: 'pr-1-2', 108 | type: 'pr', 109 | status: 'failure', 110 | timestamp: '2023-11-28T00:01:00Z', 111 | url: 'https://github.com/All-Hands-AI/OpenHands/pull/1#comment-2', 112 | description: 'The workflow to fix this issue encountered an error. Openhands failed to create any code changes.' 113 | }], 114 | lastUpdated: '2023-11-28T00:01:00Z' 115 | }); 116 | } 117 | throw new Error(`Unexpected URL: ${url}`); 118 | }); 119 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 120 | 121 | const activities = await fetchBotActivities(); 122 | 123 | expect(activities).toHaveLength(1); 124 | expect(activities[0]).toMatchObject>({ 125 | type: 'pr', 126 | status: 'failure', 127 | id: expect.stringContaining('pr-1') as string, 128 | }); 129 | 130 | // Restore the original fetch 131 | vi.unstubAllGlobals(); 132 | }); 133 | 134 | it('should handle issue with no PR', async () => { 135 | const mockFetch = vi.fn().mockImplementation((url: string) => { 136 | if (url === '/cache/bot-activities.json') { 137 | return createMockResponse({ 138 | activities: [{ 139 | id: 'issue-1-2', 140 | type: 'issue', 141 | status: 'no_pr', 142 | timestamp: '2023-11-28T00:01:00Z', 143 | url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-2', 144 | description: 'Working on the issue...' 145 | }], 146 | lastUpdated: '2023-11-28T00:01:00Z' 147 | }); 148 | } 149 | throw new Error(`Unexpected URL: ${url}`); 150 | }); 151 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 152 | 153 | const activities = await fetchBotActivities(); 154 | 155 | expect(activities).toHaveLength(1); 156 | expect(activities[0]).toMatchObject>({ 157 | type: 'issue', 158 | status: 'no_pr', 159 | }); 160 | 161 | vi.unstubAllGlobals(); 162 | }); 163 | 164 | it('should handle issue with open PR', async () => { 165 | const mockFetch = vi.fn().mockImplementation((url: string) => { 166 | if (url === '/cache/bot-activities.json') { 167 | return createMockResponse({ 168 | activities: [{ 169 | id: 'issue-1-2', 170 | type: 'issue', 171 | status: 'pr_open', 172 | timestamp: '2023-11-28T00:01:00Z', 173 | url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-2', 174 | prUrl: 'https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/2', 175 | description: 'Created PR #2' 176 | }], 177 | lastUpdated: '2023-11-28T00:01:00Z' 178 | }); 179 | } 180 | throw new Error(`Unexpected URL: ${url}`); 181 | }); 182 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 183 | 184 | const activities = await fetchBotActivities(); 185 | 186 | expect(activities).toHaveLength(1); 187 | expect(activities[0]).toMatchObject>({ 188 | type: 'issue', 189 | status: 'pr_open', 190 | }); 191 | 192 | vi.unstubAllGlobals(); 193 | }); 194 | 195 | it('should handle issue with merged PR', async () => { 196 | const mockFetch = vi.fn().mockImplementation((url: string) => { 197 | if (url === '/cache/bot-activities.json') { 198 | return createMockResponse({ 199 | activities: [{ 200 | id: 'issue-1-2', 201 | type: 'issue', 202 | status: 'pr_merged', 203 | timestamp: '2023-11-28T00:01:00Z', 204 | url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-2', 205 | prUrl: 'https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/2', 206 | description: 'Created PR #2' 207 | }], 208 | lastUpdated: '2023-11-28T00:01:00Z' 209 | }); 210 | } 211 | throw new Error(`Unexpected URL: ${url}`); 212 | }); 213 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 214 | 215 | const activities = await fetchBotActivities(); 216 | 217 | expect(activities).toHaveLength(1); 218 | expect(activities[0]).toMatchObject>({ 219 | type: 'issue', 220 | status: 'pr_merged', 221 | }); 222 | 223 | vi.unstubAllGlobals(); 224 | }); 225 | 226 | it('should handle issue with closed PR', async () => { 227 | const mockFetch = vi.fn().mockImplementation((url: string) => { 228 | if (url === '/cache/bot-activities.json') { 229 | return createMockResponse({ 230 | activities: [{ 231 | id: 'issue-1-2', 232 | type: 'issue', 233 | status: 'pr_closed', 234 | timestamp: '2023-11-28T00:01:00Z', 235 | url: 'https://github.com/All-Hands-AI/OpenHands/issues/1#comment-2', 236 | prUrl: 'https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/2', 237 | description: 'Created PR #2' 238 | }], 239 | lastUpdated: '2023-11-28T00:01:00Z' 240 | }); 241 | } 242 | throw new Error(`Unexpected URL: ${url}`); 243 | }); 244 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 245 | 246 | const activities = await fetchBotActivities(); 247 | 248 | expect(activities).toHaveLength(1); 249 | expect(activities[0]).toMatchObject>({ 250 | type: 'issue', 251 | status: 'pr_closed', 252 | }); 253 | 254 | vi.unstubAllGlobals(); 255 | }); 256 | 257 | it('should detect openhands-agent comments in PRs', async () => { 258 | // Mock cache response for PR success 259 | const mockFetch = vi.fn().mockImplementation((url: string) => { 260 | if (url === '/cache/bot-activities.json') { 261 | return createMockResponse({ 262 | activities: [{ 263 | id: 'pr-1-2', 264 | type: 'pr', 265 | status: 'success', 266 | timestamp: '2023-11-28T00:01:00Z', 267 | url: 'https://github.com/All-Hands-AI/OpenHands/pull/1#comment-2', 268 | description: 'OpenHands made the following changes to resolve the issues:\n\n- Fixed the bug in the code\n\nUpdated pull request https://github.com/All-Hands-AI/OpenHands/pull/1 with new patches.' 269 | }], 270 | lastUpdated: '2023-11-28T00:01:00Z' 271 | }); 272 | } 273 | throw new Error(`Unexpected URL: ${url}`); 274 | }); 275 | vi.stubGlobal('fetch', mockFetch as unknown as typeof fetch); 276 | 277 | const activities = await fetchBotActivities(); 278 | 279 | expect(activities).toHaveLength(1); 280 | expect(activities[0]).toMatchObject>({ 281 | type: 'pr', 282 | status: 'success', 283 | id: expect.stringContaining('pr-1') as string, 284 | }); 285 | 286 | // Restore the original fetch 287 | vi.unstubAllGlobals(); 288 | }); 289 | }); -------------------------------------------------------------------------------- /src/components/ActivityList.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/react'; 2 | import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 3 | import { ActivityList } from './ActivityList'; 4 | import { BotActivity } from '../types'; 5 | import { getComputedStyle } from '../test/testUtils'; 6 | 7 | describe('ActivityList', () => { 8 | const createMockActivity = (id: string): BotActivity => ({ 9 | id, 10 | type: 'issue', 11 | status: 'success', 12 | timestamp: '2023-11-28T12:00:00Z', 13 | url: `https://github.com/example/${id}`, 14 | title: `Test Issue ${id}`, 15 | description: `Description for issue ${id}`, 16 | }); 17 | 18 | const mockActivities: BotActivity[] = [ 19 | { 20 | id: '1', 21 | type: 'issue', 22 | status: 'success', 23 | timestamp: '2023-11-28T12:00:00Z', 24 | url: 'https://github.com/example/1', 25 | title: 'ISSUE success 11/28/2023, 12:00:00 PM -- Test Issue 1', 26 | description: 'Successfully resolved issue', 27 | }, 28 | { 29 | id: '2', 30 | type: 'pr', 31 | status: 'failure', 32 | timestamp: '2023-11-28T13:00:00Z', 33 | url: 'https://github.com/example/2', 34 | title: 'PR failure 11/28/2023, 1:00:00 PM -- Test PR 1', 35 | description: 'Failed to modify PR', 36 | }, 37 | ]; 38 | 39 | it('renders activities correctly', () => { 40 | render(); 41 | 42 | // Check if activities are rendered 43 | expect(screen.getByText('ISSUE success 11/28/2023, 12:00:00 PM -- Test Issue 1')).toBeInTheDocument(); 44 | expect(screen.getByText('PR failure 11/28/2023, 1:00:00 PM -- Test PR 1')).toBeInTheDocument(); 45 | expect(screen.getByText('Successfully resolved issue')).toBeInTheDocument(); 46 | expect(screen.getByText('Failed to modify PR')).toBeInTheDocument(); 47 | 48 | // Check if links are rendered correctly 49 | const links = screen.getAllByText('View on GitHub'); 50 | expect(links).toHaveLength(2); 51 | expect(links[0]).toHaveAttribute('href', 'https://github.com/example/1'); 52 | expect(links[1]).toHaveAttribute('href', 'https://github.com/example/2'); 53 | }); 54 | 55 | it('renders empty state correctly', () => { 56 | render(); 57 | expect(screen.queryByText('View on GitHub')).not.toBeInTheDocument(); 58 | }); 59 | 60 | it('does not show pagination controls when there are 20 or fewer items', () => { 61 | const activities = Array.from({ length: 20 }, (_, i) => createMockActivity(String(i + 1))); 62 | render(); 63 | 64 | expect(screen.queryByText('Previous')).not.toBeInTheDocument(); 65 | expect(screen.queryByText('Next')).not.toBeInTheDocument(); 66 | expect(screen.queryByText(/Page \d+ of \d+/)).not.toBeInTheDocument(); 67 | }); 68 | 69 | it('shows pagination controls when there are more than 20 items', () => { 70 | const activities = Array.from({ length: 25 }, (_, i) => createMockActivity(String(i + 1))); 71 | render(); 72 | 73 | expect(screen.getByText('Previous')).toBeInTheDocument(); 74 | expect(screen.getByText('Next')).toBeInTheDocument(); 75 | expect(screen.getByText('Page 1 of 2')).toBeInTheDocument(); 76 | }); 77 | 78 | it('shows only 20 items per page', () => { 79 | const activities = Array.from({ length: 25 }, (_, i) => createMockActivity(String(i + 1))); 80 | render(); 81 | 82 | const items = screen.getAllByText(/Test Issue \d+/); 83 | expect(items).toHaveLength(20); 84 | expect(screen.getByText('Test Issue 1')).toBeInTheDocument(); 85 | expect(screen.getByText('Test Issue 20')).toBeInTheDocument(); 86 | expect(screen.queryByText('Test Issue 21')).not.toBeInTheDocument(); 87 | }); 88 | 89 | it('navigates between pages correctly', () => { 90 | const activities = Array.from({ length: 25 }, (_, i) => createMockActivity(String(i + 1))); 91 | render(); 92 | 93 | // Initial page 94 | expect(screen.getByText('Test Issue 1')).toBeInTheDocument(); 95 | expect(screen.queryByText('Test Issue 21')).not.toBeInTheDocument(); 96 | 97 | // Navigate to next page 98 | fireEvent.click(screen.getByText('Next')); 99 | expect(screen.queryByText('Test Issue 1')).not.toBeInTheDocument(); 100 | expect(screen.getByText('Test Issue 21')).toBeInTheDocument(); 101 | expect(screen.getByText('Page 2 of 2')).toBeInTheDocument(); 102 | 103 | // Navigate back to first page 104 | fireEvent.click(screen.getByText('Previous')); 105 | expect(screen.getByText('Test Issue 1')).toBeInTheDocument(); 106 | expect(screen.queryByText('Test Issue 21')).not.toBeInTheDocument(); 107 | expect(screen.getByText('Page 1 of 2')).toBeInTheDocument(); 108 | }); 109 | 110 | it('disables pagination buttons appropriately', () => { 111 | const activities = Array.from({ length: 25 }, (_, i) => createMockActivity(String(i + 1))); 112 | render(); 113 | 114 | // On first page, Previous should be disabled 115 | expect(screen.getByText('Previous')).toBeDisabled(); 116 | expect(screen.getByText('Next')).not.toBeDisabled(); 117 | 118 | // Navigate to last page 119 | fireEvent.click(screen.getByText('Next')); 120 | expect(screen.getByText('Previous')).not.toBeDisabled(); 121 | expect(screen.getByText('Next')).toBeDisabled(); 122 | }); 123 | 124 | describe('issue status styling', () => { 125 | beforeEach(() => { 126 | // Add CSS styles for testing 127 | const style = document.createElement('style'); 128 | style.textContent = ` 129 | .activity-item.no_pr { border-left: 4px solid #ffffff; } 130 | .activity-item.pr_open { border-left: 4px solid #4caf50; } 131 | .activity-item.pr_merged { border-left: 4px solid #9c27b0; } 132 | .activity-item.pr_closed { border-left: 4px solid #f44336; } 133 | `; 134 | document.head.appendChild(style); 135 | }); 136 | 137 | afterEach(() => { 138 | // Clean up styles 139 | const styles = document.head.getElementsByTagName('style'); 140 | Array.from(styles).forEach((style) => { style.remove(); }); 141 | }); 142 | 143 | const issueStatuses = [ 144 | { status: 'no_pr', color: '#ffffff' }, 145 | { status: 'pr_open', color: '#4caf50' }, 146 | { status: 'pr_merged', color: '#9c27b0' }, 147 | { status: 'pr_closed', color: '#f44336' } 148 | ]; 149 | 150 | issueStatuses.forEach(({ status, color }) => { 151 | it(`applies correct border color for issue with ${status} status`, () => { 152 | const activity: BotActivity = { 153 | id: '1', 154 | type: 'issue', 155 | status: status as any, 156 | timestamp: '2023-11-28T12:00:00Z', 157 | url: 'https://github.com/example/1', 158 | title: `Test Issue with ${status}`, 159 | description: 'Test description', 160 | prUrl: status === 'no_pr' ? undefined : 'https://github.com/example/pr/1' 161 | }; 162 | 163 | render(); 164 | 165 | const item = document.querySelector('.activity-item') as HTMLElement; 166 | expect(item).not.toBeNull(); 167 | expect(item.classList.contains(status)).toBe(true); 168 | 169 | const styleRules = Array.from(document.styleSheets) 170 | .flatMap(sheet => Array.from(sheet.cssRules)) 171 | .map(rule => rule.cssText) 172 | .join('\n'); 173 | 174 | expect(styleRules).toContain(`.activity-item.${status}`); 175 | expect(styleRules).toContain(`border-left: 4px solid ${color}`); 176 | }); 177 | 178 | if (status !== 'no_pr') { 179 | it(`shows PR link for issue with ${status} status`, () => { 180 | const activity: BotActivity = { 181 | id: '1', 182 | type: 'issue', 183 | status: status as any, 184 | timestamp: '2023-11-28T12:00:00Z', 185 | url: 'https://github.com/example/1', 186 | title: `Test Issue with ${status}`, 187 | description: 'Test description', 188 | prUrl: 'https://github.com/example/pr/1' 189 | }; 190 | 191 | render(); 192 | 193 | const prLink = screen.getByText('View PR'); 194 | expect(prLink).toBeInTheDocument(); 195 | expect(prLink).toHaveAttribute('href', 'https://github.com/example/pr/1'); 196 | }); 197 | } 198 | }); 199 | }); 200 | 201 | describe('dark theme styling', () => { 202 | beforeEach(() => { 203 | // Set up CSS variables for dark theme 204 | document.documentElement.style.setProperty('--bg-input', '#393939'); 205 | document.documentElement.style.setProperty('--text-editor-active', '#C4CBDA'); 206 | document.documentElement.style.setProperty('--border', '#3c3c4a'); 207 | document.documentElement.style.setProperty('--bg-editor-active', '#31343D'); 208 | document.documentElement.style.setProperty('--text-editor-base', '#9099AC'); 209 | 210 | // Add CSS styles for testing 211 | const style = document.createElement('style'); 212 | style.textContent = ` 213 | .pagination { 214 | display: flex; 215 | justify-content: center; 216 | align-items: center; 217 | gap: 1rem; 218 | margin-top: 2rem; 219 | } 220 | 221 | .pagination button { 222 | padding: 0.5rem 1rem; 223 | background: var(--bg-input); 224 | color: var(--text-editor-active); 225 | border: 1px solid var(--border); 226 | border-radius: 4px; 227 | cursor: pointer; 228 | transition: background-color 0.2s; 229 | } 230 | 231 | .pagination button:disabled { 232 | opacity: 0.5; 233 | cursor: not-allowed; 234 | } 235 | 236 | .pagination button:not(:disabled):hover { 237 | background: var(--bg-editor-active); 238 | } 239 | 240 | .pagination .page-info { 241 | color: var(--text-editor-base); 242 | padding: 0.5rem 1rem; 243 | } 244 | `; 245 | document.head.appendChild(style); 246 | }); 247 | 248 | afterEach(() => { 249 | // Clean up styles 250 | const styles = document.head.getElementsByTagName('style'); 251 | Array.from(styles).forEach((style) => { style.remove(); }); 252 | }); 253 | 254 | describe('pagination styling', () => { 255 | const activities = Array.from({ length: 25 }, (_, i) => createMockActivity(String(i + 1))); 256 | 257 | it('applies dark theme styles to pagination buttons', () => { 258 | render(); 259 | 260 | // Check if the style rules exist with correct variables 261 | const styleRules = Array.from(document.styleSheets) 262 | .flatMap(sheet => Array.from(sheet.cssRules)) 263 | .map(rule => rule.cssText) 264 | .join('\n'); 265 | 266 | expect(styleRules).toContain('.pagination button'); 267 | expect(styleRules).toContain('var(--bg-input)'); 268 | expect(styleRules).toContain('var(--text-editor-active)'); 269 | expect(styleRules).toContain('var(--border)'); 270 | }); 271 | 272 | it('has proper spacing between pagination elements', () => { 273 | render(); 274 | 275 | const pagination = document.querySelector('.pagination') as HTMLElement; 276 | expect(pagination).not.toBeNull(); 277 | expect(getComputedStyle(pagination, 'gap')).toBe('1rem'); 278 | expect(getComputedStyle(pagination, 'margin-top')).toBe('2rem'); 279 | }); 280 | 281 | it('applies proper styles to disabled pagination buttons', () => { 282 | render(); 283 | 284 | const prevButton = screen.getByText('Previous'); 285 | expect(prevButton).toBeDisabled(); 286 | expect(getComputedStyle(prevButton, 'opacity')).toBe('0.5'); 287 | expect(getComputedStyle(prevButton, 'cursor')).toBe('not-allowed'); 288 | }); 289 | 290 | it('applies hover styles to enabled pagination buttons', () => { 291 | render(); 292 | 293 | const nextButton = screen.getByText('Next'); 294 | expect(nextButton).not.toBeDisabled(); 295 | 296 | // Check if the hover style rule exists 297 | const styleRules = Array.from(document.styleSheets) 298 | .flatMap(sheet => Array.from(sheet.cssRules)) 299 | .map(rule => rule.cssText) 300 | .join('\n'); 301 | 302 | expect(styleRules).toContain('.pagination button:not(:disabled):hover'); 303 | expect(styleRules).toContain('var(--bg-editor-active)'); 304 | }); 305 | 306 | it('styles page info text correctly', () => { 307 | render(); 308 | 309 | const pageInfo = screen.getByText('Page 1 of 2'); 310 | expect(pageInfo.classList.contains('page-info')).toBe(true); 311 | 312 | // Check if the style rule exists 313 | const styleRules = Array.from(document.styleSheets) 314 | .flatMap(sheet => Array.from(sheet.cssRules)) 315 | .map(rule => rule.cssText) 316 | .join('\n'); 317 | 318 | expect(styleRules).toContain('.pagination .page-info'); 319 | expect(styleRules).toContain('var(--text-editor-base)'); 320 | expect(styleRules).toContain('padding: 0.5rem 1rem'); 321 | }); 322 | 323 | it('has proper button padding and border radius', () => { 324 | render(); 325 | 326 | const nextButton = screen.getByText('Next'); 327 | expect(getComputedStyle(nextButton, 'padding')).toBe('0.5rem 1rem'); 328 | expect(getComputedStyle(nextButton, 'border-radius')).toBe('4px'); 329 | }); 330 | }); 331 | }); 332 | }); -------------------------------------------------------------------------------- /scripts/src/github-api.ts: -------------------------------------------------------------------------------- 1 | import type { GitHubComment, GitHubIssue, GitHubPR, GitHubPRResponse, ApiResponse, Activity, IssueStatus, PRStatus } from './types'; 2 | import fetch from 'node-fetch'; 3 | import { performance } from 'perf_hooks'; 4 | 5 | const GITHUB_TOKEN = process.env['GITHUB_TOKEN'] ?? ''; 6 | const REPO_OWNER = 'All-Hands-AI'; 7 | const REPO_NAME = 'OpenHands'; 8 | 9 | import fs from 'fs'; 10 | 11 | const MAX_RETRIES = 3; 12 | const RETRY_DELAY = 1000; // 1 second 13 | 14 | async function sleep(ms: number) { 15 | return new Promise(resolve => setTimeout(resolve, ms)); 16 | } 17 | 18 | async function fetchWithAuth(url: string, retries = MAX_RETRIES): Promise> { 19 | // Log the request 20 | fs.appendFileSync('github-api.log', `\n[${new Date().toISOString()}] REQUEST: ${url}\n`); 21 | 22 | try { 23 | const response = await fetch(url, { 24 | headers: { 25 | 'Authorization': `Bearer ${GITHUB_TOKEN}`, 26 | 'Accept': 'application/vnd.github.v3+json', 27 | 'User-Agent': 'OpenHands-Agent-Monitor' 28 | }, 29 | }); 30 | 31 | // Check for rate limiting 32 | if (response.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') { 33 | const resetTime = parseInt(response.headers.get('x-ratelimit-reset') || '0') * 1000; 34 | const waitTime = Math.max(0, resetTime - Date.now()); 35 | console.log(`Rate limited. Waiting ${String(waitTime)}ms before retrying...`); 36 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] RATE LIMIT: Waiting ${String(waitTime)}ms before retry\n`); 37 | await sleep(waitTime); 38 | return await fetchWithAuth(url, retries); 39 | } 40 | 41 | if (!response.ok) { 42 | const errorBody = await response.text(); 43 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] ERROR: ${String(response.status)} ${String(response.statusText)}\n${String(errorBody)}\n`); 44 | 45 | if (retries > 0) { 46 | console.log(`Request failed. Retrying in ${String(RETRY_DELAY)}ms... (${String(retries)} retries left)`); 47 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] RETRY: ${String(retries)} attempts remaining\n`); 48 | await sleep(RETRY_DELAY); 49 | return await fetchWithAuth(url, retries - 1); 50 | } 51 | 52 | throw new Error(`GitHub API error: ${String(response.status)} ${String(response.statusText)}\n${String(errorBody)}`); 53 | } 54 | 55 | // Parse Link header for pagination 56 | const linkHeader = response.headers.get('Link') ?? ''; 57 | let hasNextPage = false; 58 | let nextUrl: string | null = null; 59 | 60 | if (linkHeader !== '') { 61 | const links = linkHeader.split(','); 62 | for (const link of links) { 63 | const [url, rel] = link.split(';'); 64 | if (rel?.includes('rel="next"')) { 65 | hasNextPage = true; 66 | nextUrl = url?.trim()?.slice(1, -1) ?? null; // Remove < and > 67 | break; 68 | } 69 | } 70 | } 71 | 72 | const data = await response.json(); 73 | // Log the response 74 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] RESPONSE: ${JSON.stringify(data, null, 2)}\n`); 75 | return { data, hasNextPage, nextUrl }; 76 | } catch (error) { 77 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] ERROR: ${String(error)}\n`); 78 | throw error; 79 | } 80 | } 81 | 82 | async function fetchAllPages(url: string): Promise { 83 | const allItems: T[] = []; 84 | let currentUrl = url; 85 | let pageCount = 0; 86 | 87 | while (currentUrl !== '') { 88 | pageCount++; 89 | console.log(`Fetching page ${pageCount.toString()} from ${currentUrl}`); 90 | const response = await fetchWithAuth(currentUrl); 91 | console.log(`Got ${Array.isArray(response.data) ? String(response.data.length) : '1'} items`); 92 | if (Array.isArray(response.data)) { 93 | allItems.push(...response.data); 94 | } else { 95 | allItems.push(response.data); 96 | } 97 | currentUrl = response.nextUrl ?? ''; 98 | } 99 | 100 | console.log(`Total items fetched: ${allItems.length.toString()}`); 101 | return allItems; 102 | } 103 | 104 | export function isBotComment(comment: GitHubComment): boolean { 105 | return comment.user.login === 'openhands-agent' || 106 | (comment.user.login === 'github-actions[bot]' && comment.user.type === 'Bot'); 107 | } 108 | 109 | export function isStartWorkComment(comment: GitHubComment): boolean { 110 | if (!isBotComment(comment)) return false; 111 | const lowerBody = comment.body.toLowerCase(); 112 | return lowerBody.includes('started fixing the') || 113 | lowerBody.includes('openhands started fixing'); 114 | } 115 | 116 | export function isSuccessComment(comment: GitHubComment): boolean { 117 | if (!isBotComment(comment)) { 118 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] COMMENT CHECK: Not a bot comment - ${comment.user.login}\n`); 119 | return false; 120 | } 121 | const lowerBody = comment.body.toLowerCase(); 122 | const isSuccess = lowerBody.includes('a potential fix has been generated') || 123 | lowerBody.includes('openhands made the following changes to resolve the issues') || 124 | lowerBody.includes('successfully fixed') || 125 | lowerBody.includes('updated pull request'); 126 | fs.appendFileSync('github-api.log', `[${new Date().toISOString()}] COMMENT CHECK: Bot comment - Success: ${String(isSuccess)}\nBody: ${comment.body}\n`); 127 | return isSuccess; 128 | } 129 | 130 | export function isFailureComment(comment: GitHubComment): boolean { 131 | if (!isBotComment(comment)) return false; 132 | const lowerBody = comment.body.toLowerCase(); 133 | return lowerBody.includes('the workflow to fix this issue encountered an error') || 134 | lowerBody.includes('openhands failed to create any code changes') || 135 | lowerBody.includes('an attempt was made to automatically fix this issue, but it was unsuccessful'); 136 | } 137 | 138 | export function isPRModificationComment(comment: GitHubComment): boolean { 139 | return isStartWorkComment(comment); 140 | } 141 | 142 | export function isPRModificationSuccessComment(comment: GitHubComment): boolean { 143 | return isSuccessComment(comment); 144 | } 145 | 146 | export function isPRModificationFailureComment(comment: GitHubComment): boolean { 147 | return isFailureComment(comment); 148 | } 149 | 150 | async function checkPRStatus(prUrl: string): Promise { 151 | try { 152 | const response = await fetchWithAuth(prUrl); 153 | const pr = response.data as GitHubPRResponse; 154 | 155 | if (pr?.merged) { 156 | return 'pr_merged'; 157 | } else if (pr?.state === 'closed') { 158 | return 'pr_closed'; 159 | } else { 160 | return 'pr_open'; 161 | } 162 | } catch (error) { 163 | console.error('Error checking PR status:', error); 164 | return 'no_pr'; 165 | } 166 | } 167 | 168 | async function processIssueComments(issue: GitHubIssue): Promise { 169 | const activities: Activity[] = []; 170 | const comments = await fetchAllPages(issue.comments_url); 171 | 172 | for (let i = 0; i < comments.length; i++) { 173 | const comment = comments[i]; 174 | if (comment && isStartWorkComment(comment)) { 175 | // Look for the next relevant comment to determine success/failure 176 | const nextComments = comments.slice(i + 1); 177 | const successComment = nextComments.find(isSuccessComment); 178 | const failureComment = nextComments.find(isFailureComment); 179 | const resultComment = successComment ?? failureComment; 180 | 181 | if (resultComment !== undefined) { 182 | const timestamp = new Date(resultComment.created_at).toLocaleString(); 183 | 184 | // Extract PR URL from success comment if available 185 | let prUrl: string | undefined; 186 | let status: IssueStatus = 'no_pr'; 187 | 188 | if (successComment) { 189 | // Try different PR reference formats 190 | let prNumber: string | undefined; 191 | 192 | // Try full PR URL format 193 | const fullUrlMatch = successComment.body.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); 194 | if (fullUrlMatch) { 195 | prNumber = fullUrlMatch[1]; 196 | } 197 | 198 | // Try pull/123 format 199 | if (!prNumber) { 200 | const pullMatch = successComment.body.match(/pull\/(\d+)/); 201 | if (pullMatch) { 202 | prNumber = pullMatch[1]; 203 | } 204 | } 205 | 206 | // Try #123 format when it refers to a PR 207 | if (!prNumber) { 208 | const hashMatch = successComment.body.match(/PR #(\d+)/i); 209 | if (hashMatch) { 210 | prNumber = hashMatch[1]; 211 | } 212 | } 213 | 214 | if (prNumber) { 215 | prUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/pulls/${prNumber}`; 216 | // Check PR status 217 | status = await checkPRStatus(prUrl); 218 | } 219 | } 220 | 221 | activities.push({ 222 | id: `issue-${String(issue.number)}-${String(comment.id)}`, 223 | type: 'issue', 224 | status, 225 | timestamp: resultComment.created_at, 226 | url: resultComment.html_url, 227 | title: `ISSUE ${status} ${timestamp} -- ${issue.title}`, 228 | description: issue.body.slice(0, 500) + (issue.body.length > 500 ? '...' : ''), 229 | prUrl, 230 | }); 231 | } 232 | } 233 | } 234 | 235 | return activities; 236 | } 237 | 238 | async function processPRComments(pr: GitHubPR): Promise { 239 | const activities: Activity[] = []; 240 | const comments = await fetchAllPages(pr.comments_url); 241 | 242 | for (let i = 0; i < comments.length; i++) { 243 | const comment = comments[i]; 244 | if (comment && isPRModificationComment(comment)) { 245 | // Look for the next relevant comment to determine success/failure 246 | const nextComments = comments.slice(i + 1); 247 | const successComment = nextComments.find(isPRModificationSuccessComment); 248 | const failureComment = nextComments.find(isPRModificationFailureComment); 249 | const resultComment = successComment ?? failureComment; 250 | 251 | if (resultComment !== undefined) { 252 | const status: PRStatus = successComment !== undefined ? 'success' : 'failure'; 253 | const timestamp = new Date(resultComment.created_at).toLocaleString(); 254 | activities.push({ 255 | id: `pr-${String(pr.number)}-${String(comment.id)}`, 256 | type: 'pr', 257 | status, 258 | timestamp: resultComment.created_at, 259 | url: resultComment.html_url, 260 | title: `PR ${status} ${timestamp} -- ${pr.title}`, 261 | description: pr.body ? (pr.body.slice(0, 500) + (pr.body.length > 500 ? '...' : '')) : 'No description provided', 262 | }); 263 | } 264 | } 265 | } 266 | 267 | return activities; 268 | } 269 | 270 | // Only run main() if this file is being run directly 271 | if (require.main === module) { 272 | async function main() { 273 | try { 274 | const activities = await fetchBotActivities(); 275 | process.stdout.write(JSON.stringify(activities, null, 2)); 276 | } catch (error) { 277 | process.stderr.write(String(error) + '\n'); 278 | process.exit(1); 279 | } 280 | } 281 | main(); 282 | } 283 | 284 | export async function fetchBotActivities(since?: string): Promise { 285 | const startTime = performance.now(); 286 | try { 287 | if (!GITHUB_TOKEN || GITHUB_TOKEN === 'placeholder') { 288 | process.stderr.write('Error: GITHUB_TOKEN environment variable is not set or invalid\n'); 289 | throw new Error(String('Invalid GITHUB_TOKEN')); 290 | } 291 | console.log('Starting bot activities fetch...'); 292 | const activities: Activity[] = []; 293 | const baseUrl = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}`; 294 | 295 | // Fetch issues and PRs with comments 296 | const params = new URLSearchParams({ 297 | state: 'all', 298 | sort: 'updated', 299 | direction: 'desc', 300 | per_page: '100', 301 | }); 302 | 303 | if (since !== undefined && since !== '') { 304 | params.append('since', since); 305 | } else { 306 | // Default to last 30 days if no since parameter 307 | const thirtyDaysAgo = new Date(); 308 | thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); 309 | params.append('since', thirtyDaysAgo.toISOString()); 310 | } 311 | 312 | // Fetch issues and PRs 313 | console.log('Fetching issues and PRs...'); 314 | const fetchStartTime = performance.now(); 315 | const items = await fetchAllPages(`${baseUrl}/issues?${params.toString()}`); 316 | console.log(`Fetched ${String(items.length)} items in ${((performance.now() - fetchStartTime) / 1000).toFixed(2)}s`); 317 | 318 | console.log('Processing items...'); 319 | const processStartTime = performance.now(); 320 | // Filter items that have comments 321 | const itemsWithComments = items.filter(item => item.comments > 0); 322 | console.log(`Processing ${String(itemsWithComments.length)} items with comments in parallel...`); 323 | 324 | // Process items in parallel 325 | const batchSize = 10; // Process 10 items at a time to avoid rate limiting 326 | const results = []; 327 | 328 | for (let i = 0; i < itemsWithComments.length; i += batchSize) { 329 | const batch = itemsWithComments.slice(i, i + batchSize); 330 | const batchNumber = Math.floor(i/batchSize) + 1; 331 | const totalBatches = Math.ceil(itemsWithComments.length/batchSize); 332 | console.log(`Processing batch ${String(batchNumber)}/${String(totalBatches)}...`); 333 | 334 | const batchResults = await Promise.all( 335 | batch.map(async item => { 336 | if (item.pull_request === undefined) { 337 | // Process regular issues 338 | return processIssueComments(item); 339 | } else { 340 | // Process PRs through the issue comments endpoint to catch all activity 341 | return processPRComments({ 342 | number: item.number, 343 | title: item.title, 344 | html_url: item.html_url, 345 | comments_url: item.comments_url, 346 | comments: item.comments, 347 | body: item.body 348 | }); 349 | } 350 | }) 351 | ); 352 | 353 | results.push(...batchResults); 354 | } 355 | 356 | // Flatten results and add to activities 357 | activities.push(...results.flat()); 358 | 359 | console.log(`Processed all items in ${((performance.now() - processStartTime) / 1000).toFixed(2)}s`); 360 | 361 | // Sort by timestamp in descending order 362 | console.log('Sorting activities...'); 363 | const sortStartTime = performance.now(); 364 | const sortedActivities = activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); 365 | console.log(`Sorted ${String(activities.length)} activities in ${((performance.now() - sortStartTime) / 1000).toFixed(2)}s`); 366 | 367 | const totalTime = (performance.now() - startTime) / 1000; 368 | console.log(`Total execution time: ${totalTime.toFixed(2)}s`); 369 | 370 | return sortedActivities; 371 | } catch (error) { 372 | const errorMessage = error instanceof Error ? error.message : String(error); 373 | process.stderr.write('Error fetching bot activities: ' + errorMessage + '\n'); 374 | const totalTime = (performance.now() - startTime) / 1000; 375 | process.stderr.write('Total execution time: ' + totalTime.toFixed(2) + 's (failed)\n'); 376 | throw new Error(errorMessage); 377 | } 378 | } -------------------------------------------------------------------------------- /public/cache/bot-activities.json: -------------------------------------------------------------------------------- 1 | { 2 | "activities": [ 3 | { 4 | "id": "issue-5423-2531945738", 5 | "type": "issue", 6 | "status": "no_pr", 7 | "timestamp": "2024-12-10T15:12:26Z", 8 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5423#issuecomment-2531962130", 9 | "title": "ISSUE no_pr 12/10/2024, 3:12:26 PM -- [Bug]: When Performing browser operations, output messages are repeated", 10 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nStart a new session and enter the prompt: `What is the area in square miles of the state of California?`\r\nThe response `Let me search for this information.` is repeated twice before the actual answer is received.\r\n\r\n### OpenHands Installation\r\n\r\nDocker command in README\r\n\r\n### OpenHands Version\r\n\r\n0.15.0\r\n\r\n### Operating System\r\n\r\nMacOS\r\n\r\n### Logs, Erro..." 11 | }, 12 | { 13 | "id": "issue-5423-2531932774", 14 | "type": "issue", 15 | "status": "no_pr", 16 | "timestamp": "2024-12-10T15:04:35Z", 17 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5423#issuecomment-2531939912", 18 | "title": "ISSUE no_pr 12/10/2024, 3:04:35 PM -- [Bug]: When Performing browser operations, output messages are repeated", 19 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nStart a new session and enter the prompt: `What is the area in square miles of the state of California?`\r\nThe response `Let me search for this information.` is repeated twice before the actual answer is received.\r\n\r\n### OpenHands Installation\r\n\r\nDocker command in README\r\n\r\n### OpenHands Version\r\n\r\n0.15.0\r\n\r\n### Operating System\r\n\r\nMacOS\r\n\r\n### Logs, Erro..." 20 | }, 21 | { 22 | "id": "pr-5498-2530407818", 23 | "type": "pr", 24 | "status": "failure", 25 | "timestamp": "2024-12-10T05:12:15Z", 26 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5498#issuecomment-2530416981", 27 | "title": "PR failure 12/10/2024, 5:12:15 AM -- Fix issue #5492: [Bug]: Regression on Anthropic due to empty content hack for Bedrock", 28 | "description": "This pull request fixes #5492.\n\nThe issue has been successfully resolved. The PR addresses the root cause by:\n\n1. Making the empty content removal logic provider-specific, only applying it to Bedrock\n2. Modifying the message serialization to pass the provider information through the pipeline\n3. Adding comprehensive test coverage to verify the behavior works correctly for both Bedrock and Anthropic providers\n\nThe changes directly fix the reported KeyError by ensuring the 'content' key remains pre..." 29 | }, 30 | { 31 | "id": "issue-5480-2529235979", 32 | "type": "issue", 33 | "status": "pr_open", 34 | "timestamp": "2024-12-10T04:12:42Z", 35 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5480#issuecomment-2530257505", 36 | "title": "ISSUE pr_open 12/10/2024, 4:12:42 AM -- [Bug]: Cannot recover from \"Agent stuck in loop\"", 37 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nOnce we stuck in \"agent stuck in loop\", we cannot send subsequent messages to the agent anymore.\r\n\r\n\"image\"\r\n\n\n### OpenHands Installation\n\nDocker command in README\n\n### OpenHands Version\n\n_No response_\n\n### Operating System\n\nNone\n\n### Logs, Errors, Scree...", 38 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5500" 39 | }, 40 | { 41 | "id": "issue-5480-2530243035", 42 | "type": "issue", 43 | "status": "pr_open", 44 | "timestamp": "2024-12-10T04:12:42Z", 45 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5480#issuecomment-2530257505", 46 | "title": "ISSUE pr_open 12/10/2024, 4:12:42 AM -- [Bug]: Cannot recover from \"Agent stuck in loop\"", 47 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nOnce we stuck in \"agent stuck in loop\", we cannot send subsequent messages to the agent anymore.\r\n\r\n\"image\"\r\n\n\n### OpenHands Installation\n\nDocker command in README\n\n### OpenHands Version\n\n_No response_\n\n### Operating System\n\nNone\n\n### Logs, Errors, Scree...", 48 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5500" 49 | }, 50 | { 51 | "id": "issue-5492-2530226892", 52 | "type": "issue", 53 | "status": "pr_open", 54 | "timestamp": "2024-12-10T04:07:25Z", 55 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5492#issuecomment-2530244805", 56 | "title": "ISSUE pr_open 12/10/2024, 4:07:25 AM -- [Bug]: Regression on Anthropic due to empty content hack for Bedrock", 57 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nThis fix https://github.com/All-Hands-AI/OpenHands/pull/4935 to special case empty content string for Bedrock is causing regression with Anthropic tool call that has empty output.\r\n\r\nThis is the bug:\r\n```\r\nmessage.py#_list_serializer\r\n # pop content if it's empty\r\n if not content or (\r\n len(content) == 1\r\n and content[...", 58 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5498" 59 | }, 60 | { 61 | "id": "pr-5483-2529725095", 62 | "type": "pr", 63 | "status": "failure", 64 | "timestamp": "2024-12-09T23:28:24Z", 65 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5483#issuecomment-2529777556", 66 | "title": "PR failure 12/9/2024, 11:28:24 PM -- Fix issue #5478: Add color to the line next to \"Ran a XXX Command\" based on return value", 67 | "description": "This pull request fixes #5478.\n\nThe issue has been successfully resolved. The AI implemented a complete solution that addresses the original request to visually indicate command execution success/failure through line colors. Specifically:\n\n1. Added necessary data structures (success property) in both frontend and backend\n2. Implemented color-coding logic:\n - Green for successful commands (exit code 0)\n - Red for failed commands (non-zero exit code)\n - Gray for other messages (default)\n3. M..." 68 | }, 69 | { 70 | "id": "pr-5483-2529345378", 71 | "type": "pr", 72 | "status": "failure", 73 | "timestamp": "2024-12-09T20:13:26Z", 74 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5483#issuecomment-2529349069", 75 | "title": "PR failure 12/9/2024, 8:13:26 PM -- Fix issue #5478: Add color to the line next to \"Ran a XXX Command\" based on return value", 76 | "description": "This pull request fixes #5478.\n\nThe issue has been successfully resolved. The AI implemented a complete solution that addresses the original request to visually indicate command execution success/failure through line colors. Specifically:\n\n1. Added necessary data structures (success property) in both frontend and backend\n2. Implemented color-coding logic:\n - Green for successful commands (exit code 0)\n - Red for failed commands (non-zero exit code)\n - Gray for other messages (default)\n3. M..." 77 | }, 78 | { 79 | "id": "pr-5484-2529337741", 80 | "type": "pr", 81 | "status": "failure", 82 | "timestamp": "2024-12-09T20:11:41Z", 83 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5484#issuecomment-2529346060", 84 | "title": "PR failure 12/9/2024, 8:11:41 PM -- Remove Beta label from Browser tab", 85 | "description": "This PR removes the Beta label from the Browser tab in the frontend interface.\n\n---\n\nTo run this PR locally, use the following command:\n```\ndocker run -it --rm -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock --add-host host.docker.internal:host-gateway -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0df0ea7-nikolaik --name openhands-app-0df0ea7 docker.all-hands.dev/all-hands-ai/openhands:0df0ea7\n```" 86 | }, 87 | { 88 | "id": "issue-5478-2529207532", 89 | "type": "issue", 90 | "status": "pr_open", 91 | "timestamp": "2024-12-09T19:44:44Z", 92 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5478#issuecomment-2529253200", 93 | "title": "ISSUE pr_open 12/9/2024, 7:44:44 PM -- Add color to the line next to \"Ran a XXX Command\" based on return value", 94 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nIn the interface, we have \"Ran a Bash Command\" or \"Ran a Jupyter Command\" with a line next to it.\r\n\r\n\"Screenshot\r\n\r\nIt would be nice to know if the command was successful or not.\r\n\r\n**Describe the UX of the solution you'd like**\r\n\r\nWe could change the color of the line depending on the return value. If the c...", 95 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5483" 96 | }, 97 | { 98 | "id": "issue-5439-2529212562", 99 | "type": "issue", 100 | "status": "pr_open", 101 | "timestamp": "2024-12-09T19:37:51Z", 102 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5439#issuecomment-2529227186", 103 | "title": "ISSUE pr_open 12/9/2024, 7:37:51 PM -- [Bug]: Scrollbar for the file goes out of screen", 104 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nNot sure if this is because the file is really large but the scroll goes beyond what's the displayed on the screen.\r\n\r\n![Screenshot 2024-12-06 at 12 31 59 PM](https://github.com/user-attachments/assets/bcba1770-1689-456d-b082-b7f96b1fea13)\r\n\r\n\r\n### OpenHands Installation\r\n\r\nDocker command in README\r\n\r\n### OpenHands Version\r\n\r\nmain\r\n\r\n### Operating System...", 105 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5481" 106 | }, 107 | { 108 | "id": "issue-5465-2529206198", 109 | "type": "issue", 110 | "status": "no_pr", 111 | "timestamp": "2024-12-09T19:34:14Z", 112 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5465#issuecomment-2529214825", 113 | "title": "ISSUE no_pr 12/9/2024, 7:34:14 PM -- [Bug]: [Resolver] PR (without a label?) fails workflow", 114 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nI asked openhands-agent to do something, using the macro.\r\nhttps://github.com/All-Hands-AI/OpenHands/pull/5435#issuecomment-2525041512\r\n\r\nAction log:\r\n\r\n> Run if [[ \"\" == \"fix-me-experimental\" ]] ||\r\n if [[ \"\" == \"fix-me-experimental\" ]] ||\r\n ([[ \"issue_comment\" == \"issue_comment\" || \"issue_comment\" == \"pull_request_review_comment\" ]] &&\r\n [[ \"..." 115 | }, 116 | { 117 | "id": "issue-5471-2529155965", 118 | "type": "issue", 119 | "status": "pr_merged", 120 | "timestamp": "2024-12-09T19:19:50Z", 121 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5471#issuecomment-2529162588", 122 | "title": "ISSUE pr_merged 12/9/2024, 7:19:50 PM -- Resolver: LLM_MODEL should use \"variable\" instead of \"secret\" ", 123 | "description": "**What problem or use case are you trying to solve?**\nCurrently it's impossible to see in the Resolver logs what LLM was used and it seems unnecessary to hide it. \n\n**Describe the UX of the solution you'd like**\nAny settings that aren't sensitive should use action \"variables\" instead of \"secrets\".\n\n**Do you have thoughts on the technical implementation?**\n\n**Describe alternatives you've considered**\n\n**Additional context**\n", 124 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5477" 125 | }, 126 | { 127 | "id": "issue-5456-2525313465", 128 | "type": "issue", 129 | "status": "pr_closed", 130 | "timestamp": "2024-12-07T21:32:49Z", 131 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5456#issuecomment-2525317055", 132 | "title": "ISSUE pr_closed 12/7/2024, 9:32:49 PM -- [Bug]: Too many detected issues in github resolver", 133 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nThere are too many issues detected in the github resolver. For instance, running on this PR: https://github.com/All-Hands-AI/OpenHands/pull/5449\r\n\r\nFinds many issues that are not mentioned anywhere in the PR:\r\n```\r\n$ poetry run python openhands/resolver/resolve_issue.py --repo all-hands-ai/openhands --issue-type pr --issue-number 5432\r\n16:15:54 - openhands:INF...", 134 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5457" 135 | }, 136 | { 137 | "id": "issue-5450-2525287781", 138 | "type": "issue", 139 | "status": "pr_merged", 140 | "timestamp": "2024-12-07T19:37:04Z", 141 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5450#issuecomment-2525288775", 142 | "title": "ISSUE pr_merged 12/7/2024, 7:37:04 PM -- In openhands-resolver.yml, request code review from the person who initiated the workflow", 143 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nopenhands-resolver.yml often results in OpenHands opening a pull request on a repository, but these pull requests can become lost.\r\n\r\n**Describe the UX of the solution you'd like**\r\n\r\nWhen the Open Hands resolver initiates or updates a pull request, it should request review from the person who initiated the GitHub workflow. This will put the pr on the list of the person who initiated the workflow, making it less likely that they will miss ...", 144 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5451" 145 | }, 146 | { 147 | "id": "issue-5448-2525284593", 148 | "type": "issue", 149 | "status": "pr_open", 150 | "timestamp": "2024-12-07T19:27:48Z", 151 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5448#issuecomment-2525286456", 152 | "title": "ISSUE pr_open 12/7/2024, 7:27:48 PM -- When using the GitHub resolver on a PR, automatically pipe in failure info", 153 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nOne common use case of the GitHub resolver is to update a PR, and it is also frequent that the pr will have CI actions failing or merge conflicts. If we indicate to the agent that CI actions are failing or there are merge conflicts, we could improve the possibility that it will fix these while making any other changes.\r\n\r\n**Describe the UX of the solution you'd like**\r\n\r\nWhen they get help is over starts working on a PR, we make calls to t...", 154 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5449" 155 | }, 156 | { 157 | "id": "pr-5435-2524954159", 158 | "type": "pr", 159 | "status": "failure", 160 | "timestamp": "2024-12-07T08:03:00Z", 161 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5435#issuecomment-2524997119", 162 | "title": "PR failure 12/7/2024, 8:03:00 AM -- Fix issue #4706: [Feature]: Give descriptive name to downloaded zip file", 163 | "description": "**End-user friendly description of the problem this fixes or functionality that this introduces**\r\n\r\n- [ ] Include this change in the Release Notes. If checked, you must provide an **end-user friendly** description for your change below\r\n\r\n---\r\n**Give a summary of what the PR does, explaining any non-trivial design decisions**\r\n\r\nThe issue has not been successfully resolved. The last message shows that the test command failed with exit code 1, indicating that the unit tests are failing. This sug..." 164 | }, 165 | { 166 | "id": "pr-5435-2524996237", 167 | "type": "pr", 168 | "status": "failure", 169 | "timestamp": "2024-12-07T08:03:00Z", 170 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5435#issuecomment-2524997119", 171 | "title": "PR failure 12/7/2024, 8:03:00 AM -- Fix issue #4706: [Feature]: Give descriptive name to downloaded zip file", 172 | "description": "**End-user friendly description of the problem this fixes or functionality that this introduces**\r\n\r\n- [ ] Include this change in the Release Notes. If checked, you must provide an **end-user friendly** description for your change below\r\n\r\n---\r\n**Give a summary of what the PR does, explaining any non-trivial design decisions**\r\n\r\nThe issue has not been successfully resolved. The last message shows that the test command failed with exit code 1, indicating that the unit tests are failing. This sug..." 173 | }, 174 | { 175 | "id": "issue-5445-2524954999", 176 | "type": "issue", 177 | "status": "pr_open", 178 | "timestamp": "2024-12-07T06:01:26Z", 179 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5445#issuecomment-2524956314", 180 | "title": "ISSUE pr_open 12/7/2024, 6:01:26 AM -- [Bug]: Assigning label on PR doesn't kickstart resolver", 181 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nTried adding `fix-me`/`fix-me-experimental` label to #5342 but it didn't kickstart the resolver\n\n### OpenHands Installation\n\nOther\n\n### OpenHands Version\n\nlatest,main\n\n### Operating System\n\nNone\n\n### Logs, Errors, Screenshots, and Additional Context\n\n_No response_", 182 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5446" 183 | }, 184 | { 185 | "id": "issue-4706-2522125016", 186 | "type": "issue", 187 | "status": "no_pr", 188 | "timestamp": "2024-12-06T16:29:03Z", 189 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/4706#issuecomment-2523682063", 190 | "title": "ISSUE no_pr 12/6/2024, 4:29:03 PM -- [Feature]: Give descriptive name to downloaded zip file", 191 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nCurrently, when we use the frontend feature to download a zip file, it is always called `workspace.zip`. If you do this over and over again, you get a lot of files called `workspace.zip` and it's a little bit annoying to keep track of which is which.\r\n\r\n**Describe the UX of the solution you'd like**\r\n\r\nIt would be nice if this file was given a descriptive name.\r\n\r\n**Do you have thoughts on the technical implementation?**\r\n\r\nWhen the \"downl..." 192 | }, 193 | { 194 | "id": "issue-4706-2523586809", 195 | "type": "issue", 196 | "status": "no_pr", 197 | "timestamp": "2024-12-06T16:29:03Z", 198 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/4706#issuecomment-2523682063", 199 | "title": "ISSUE no_pr 12/6/2024, 4:29:03 PM -- [Feature]: Give descriptive name to downloaded zip file", 200 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nCurrently, when we use the frontend feature to download a zip file, it is always called `workspace.zip`. If you do this over and over again, you get a lot of files called `workspace.zip` and it's a little bit annoying to keep track of which is which.\r\n\r\n**Describe the UX of the solution you'd like**\r\n\r\nIt would be nice if this file was given a descriptive name.\r\n\r\n**Do you have thoughts on the technical implementation?**\r\n\r\nWhen the \"downl..." 201 | }, 202 | { 203 | "id": "issue-5359-2516062933", 204 | "type": "issue", 205 | "status": "no_pr", 206 | "timestamp": "2024-12-04T03:58:51Z", 207 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5359#issuecomment-2516134796", 208 | "title": "ISSUE no_pr 12/4/2024, 3:58:51 AM -- [Resolver] The LLM uses a non-optimal way to install openhands", 209 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nCheck the openhands resolver functionality.\r\nWhen the openhands resolver is running:\r\n\r\nI see these days the LLM:\r\n- trying to run pytest\r\n- finds out it's not installed, so it runs `poetry install`\r\n- it has in the prompt the info that it should set up the project with `make build`, but that doesn't seem to happen\r\n\r\nThe problem is that `poetry install` installs all optional dependencies like `torch`, `nvidia-cuda*` etc.\r\n\r\nBy default, `m..." 210 | }, 211 | { 212 | "id": "issue-5359-2516126395", 213 | "type": "issue", 214 | "status": "no_pr", 215 | "timestamp": "2024-12-04T03:58:51Z", 216 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5359#issuecomment-2516134796", 217 | "title": "ISSUE no_pr 12/4/2024, 3:58:51 AM -- [Resolver] The LLM uses a non-optimal way to install openhands", 218 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nCheck the openhands resolver functionality.\r\nWhen the openhands resolver is running:\r\n\r\nI see these days the LLM:\r\n- trying to run pytest\r\n- finds out it's not installed, so it runs `poetry install`\r\n- it has in the prompt the info that it should set up the project with `make build`, but that doesn't seem to happen\r\n\r\nThe problem is that `poetry install` installs all optional dependencies like `torch`, `nvidia-cuda*` etc.\r\n\r\nBy default, `m..." 219 | }, 220 | { 221 | "id": "issue-5383-2516059755", 222 | "type": "issue", 223 | "status": "pr_merged", 224 | "timestamp": "2024-12-04T03:24:40Z", 225 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5383#issuecomment-2516089046", 226 | "title": "ISSUE pr_merged 12/4/2024, 3:24:40 AM -- [Bug]: LLM Cost is added to the `metrics` twice", 227 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nIn the file `openhands/llm/llm.py`, when the LLM completion is invoked, the method `_completion_cost` will be called twice.\r\n\r\nOne is when preparing to store the log data for this completion:\r\nhttps://github.com/All-Hands-AI/OpenHands/blob/2f11634ccaeb3aa38893a994d53ff280bbf485d0/openhands/llm/llm.py#L231-L238\r\n\r\nThe other one is when calling the final c...", 228 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5396" 229 | }, 230 | { 231 | "id": "issue-5383-2516080771", 232 | "type": "issue", 233 | "status": "pr_merged", 234 | "timestamp": "2024-12-04T03:24:40Z", 235 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5383#issuecomment-2516089046", 236 | "title": "ISSUE pr_merged 12/4/2024, 3:24:40 AM -- [Bug]: LLM Cost is added to the `metrics` twice", 237 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nIn the file `openhands/llm/llm.py`, when the LLM completion is invoked, the method `_completion_cost` will be called twice.\r\n\r\nOne is when preparing to store the log data for this completion:\r\nhttps://github.com/All-Hands-AI/OpenHands/blob/2f11634ccaeb3aa38893a994d53ff280bbf485d0/openhands/llm/llm.py#L231-L238\r\n\r\nThe other one is when calling the final c...", 238 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5396" 239 | }, 240 | { 241 | "id": "pr-5308-2514893031", 242 | "type": "pr", 243 | "status": "failure", 244 | "timestamp": "2024-12-03T15:40:06Z", 245 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5308#issuecomment-2514913053", 246 | "title": "PR failure 12/3/2024, 3:40:06 PM -- Fix issue #5112: [Bug]: \"Push to GitHub\" shows up even if there's no repo connected", 247 | "description": "This pull request fixes #5112.\n\nThe issue has been successfully resolved. The PR addresses the core problem by fixing the logic for when the \"Push to GitHub\" button should be displayed. The key changes were:\n\n1. Adding a new `hasConnectedRepo` prop that specifically checks for an actual connected repository (via `githubData`)\n2. Modifying the display logic to require both GitHub connection AND a connected repository\n3. This ensures the button only appears when functionally useful (when there's a..." 248 | }, 249 | { 250 | "id": "pr-5284-2501338361", 251 | "type": "pr", 252 | "status": "failure", 253 | "timestamp": "2024-12-01T01:19:17Z", 254 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5284#issuecomment-2509509888", 255 | "title": "PR failure 12/1/2024, 1:19:17 AM -- feat: Add LocalRuntime and rename EventStreamRuntime to DockerRuntime", 256 | "description": "This PR adds a new LocalRuntime implementation and renames EventStreamRuntime to LocalDockerRuntime for better clarity.\r\n\r\n### Changes\r\n\r\n- Add new LocalRuntime implementation that runs action_execution_server directly on the host machine\r\n- Rename EventStreamRuntime to LocalDockerRuntime for better clarity\r\n- Move runtime implementations to dedicated directories (local/ and docker/)\r\n- Update documentation to reflect runtime changes and add LocalRuntime description\r\n\r\n### Benefits\r\n\r\n- Provides..." 257 | }, 258 | { 259 | "id": "pr-5284-2507055044", 260 | "type": "pr", 261 | "status": "failure", 262 | "timestamp": "2024-12-01T01:19:17Z", 263 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5284#issuecomment-2509509888", 264 | "title": "PR failure 12/1/2024, 1:19:17 AM -- feat: Add LocalRuntime and rename EventStreamRuntime to DockerRuntime", 265 | "description": "This PR adds a new LocalRuntime implementation and renames EventStreamRuntime to LocalDockerRuntime for better clarity.\r\n\r\n### Changes\r\n\r\n- Add new LocalRuntime implementation that runs action_execution_server directly on the host machine\r\n- Rename EventStreamRuntime to LocalDockerRuntime for better clarity\r\n- Move runtime implementations to dedicated directories (local/ and docker/)\r\n- Update documentation to reflect runtime changes and add LocalRuntime description\r\n\r\n### Benefits\r\n\r\n- Provides..." 266 | }, 267 | { 268 | "id": "pr-5284-2508709941", 269 | "type": "pr", 270 | "status": "failure", 271 | "timestamp": "2024-12-01T01:19:17Z", 272 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5284#issuecomment-2509509888", 273 | "title": "PR failure 12/1/2024, 1:19:17 AM -- feat: Add LocalRuntime and rename EventStreamRuntime to DockerRuntime", 274 | "description": "This PR adds a new LocalRuntime implementation and renames EventStreamRuntime to LocalDockerRuntime for better clarity.\r\n\r\n### Changes\r\n\r\n- Add new LocalRuntime implementation that runs action_execution_server directly on the host machine\r\n- Rename EventStreamRuntime to LocalDockerRuntime for better clarity\r\n- Move runtime implementations to dedicated directories (local/ and docker/)\r\n- Update documentation to reflect runtime changes and add LocalRuntime description\r\n\r\n### Benefits\r\n\r\n- Provides..." 275 | }, 276 | { 277 | "id": "pr-5284-2509025424", 278 | "type": "pr", 279 | "status": "failure", 280 | "timestamp": "2024-12-01T01:19:17Z", 281 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5284#issuecomment-2509509888", 282 | "title": "PR failure 12/1/2024, 1:19:17 AM -- feat: Add LocalRuntime and rename EventStreamRuntime to DockerRuntime", 283 | "description": "This PR adds a new LocalRuntime implementation and renames EventStreamRuntime to LocalDockerRuntime for better clarity.\r\n\r\n### Changes\r\n\r\n- Add new LocalRuntime implementation that runs action_execution_server directly on the host machine\r\n- Rename EventStreamRuntime to LocalDockerRuntime for better clarity\r\n- Move runtime implementations to dedicated directories (local/ and docker/)\r\n- Update documentation to reflect runtime changes and add LocalRuntime description\r\n\r\n### Benefits\r\n\r\n- Provides..." 284 | }, 285 | { 286 | "id": "pr-5284-2509489022", 287 | "type": "pr", 288 | "status": "failure", 289 | "timestamp": "2024-12-01T01:19:17Z", 290 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5284#issuecomment-2509509888", 291 | "title": "PR failure 12/1/2024, 1:19:17 AM -- feat: Add LocalRuntime and rename EventStreamRuntime to DockerRuntime", 292 | "description": "This PR adds a new LocalRuntime implementation and renames EventStreamRuntime to LocalDockerRuntime for better clarity.\r\n\r\n### Changes\r\n\r\n- Add new LocalRuntime implementation that runs action_execution_server directly on the host machine\r\n- Rename EventStreamRuntime to LocalDockerRuntime for better clarity\r\n- Move runtime implementations to dedicated directories (local/ and docker/)\r\n- Update documentation to reflect runtime changes and add LocalRuntime description\r\n\r\n### Benefits\r\n\r\n- Provides..." 293 | }, 294 | { 295 | "id": "pr-5332-2509492299", 296 | "type": "pr", 297 | "status": "failure", 298 | "timestamp": "2024-12-01T00:42:28Z", 299 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5332#issuecomment-2509498863", 300 | "title": "PR failure 12/1/2024, 12:42:28 AM -- Fix issue #4864: [Bug]: make start-backend results in NotImplementedError: Non-relative patterns are unsupported", 301 | "description": "This pull request fixes #4864.\n\nThe issue has been successfully resolved with a simple but effective fix. The original error was caused by using absolute paths with uvicorn's `--reload-exclude` option, which isn't supported by Python's `pathlib.glob()`. \n\nThe solution implemented was to modify the Makefile's `start-backend` target to use a relative path `\"./workspace\"` instead of `$(shell pwd)/workspace`. This change resolved the `NotImplementedError` that was preventing the backend from startin..." 302 | }, 303 | { 304 | "id": "issue-5112-2504963369", 305 | "type": "issue", 306 | "status": "pr_open", 307 | "timestamp": "2024-11-27T23:19:45Z", 308 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5112#issuecomment-2504967807", 309 | "title": "ISSUE pr_open 11/27/2024, 11:19:45 PM -- [Bug]: \"Push to GitHub\" shows up even if there's no repo connected", 310 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\n\"Screenshot\r\n\r\nRepro:\r\n* start a new project from scratch\r\n* push to github appears\r\n\r\nMy guess is it currently shows up if you're logged into GitHub, rather than if there's a repo connected to the current project. The latter is...", 311 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5308" 312 | }, 313 | { 314 | "id": "issue-5234-2502452335", 315 | "type": "issue", 316 | "status": "no_pr", 317 | "timestamp": "2024-11-27T01:46:40Z", 318 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5234#issuecomment-2502467549", 319 | "title": "ISSUE no_pr 11/27/2024, 1:46:40 AM -- [Bug]: [Resolver] Error when parsing the JSON response on success", 320 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nThis seems to happen all the time: (I think? I don't see a successful comment even if the agent did solve the task, please let me know if I'm doing something wrong, I'm not too familiar with the resolver ❤️ )\r\n\r\nNOTE: the issue is that success_explanation fails parsing as JSON.\r\n\r\n```\r\n06:13:25 - openhands:ERROR: resolve_issue.py:260 - Failed to parse su..." 321 | }, 322 | { 323 | "id": "issue-2947-2496600387", 324 | "type": "issue", 325 | "status": "no_pr", 326 | "timestamp": "2024-11-25T03:43:52Z", 327 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/2947#issuecomment-2496615880", 328 | "title": "ISSUE no_pr 11/25/2024, 3:43:52 AM -- Feat: make use of litellm's response \"usage\" data", 329 | "description": "**What problem or use case are you trying to solve?**\r\n\r\n**TASK**\r\n\r\nWe want to enhance our get_token_count() implementation in llm.py, to take advantage of the token counts we are provided from our dependencies, if available, and only fallback to count them if they are not already available.\r\n\r\nRead the llm.py file. It uses the litellm library, and you can find some code that uses the Usage object from litellm, too.\r\n\r\nThis `usage` data, when available, provides live token counts. It will be av..." 330 | }, 331 | { 332 | "id": "issue-5015-2496303049", 333 | "type": "issue", 334 | "status": "no_pr", 335 | "timestamp": "2024-11-24T23:02:07Z", 336 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5015#issuecomment-2496306399", 337 | "title": "ISSUE no_pr 11/24/2024, 11:02:07 PM -- [Bug]: Headless mode awaits for requested user feedback without showing any text for what that feedback should be", 338 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nCommand to run:\r\n\r\n```\r\ndocker run -it \\\r\n --pull=always \\\r\n -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.13-nikolaik \\\r\n -e SANDBOX_USER_ID=$(id -u) \\\r\n -e WORKSPACE_MOUNT_PATH=$(pwd) \\\r\n -e LLM_API_KEY=*** \\\r\n -e LLM_MODEL=\"anthropic/claude-3-5-sonnet-20241022\" \\\r\n -v $WORKSPACE_BASE:/opt/workspace..." 339 | }, 340 | { 341 | "id": "issue-5154-2495788641", 342 | "type": "issue", 343 | "status": "pr_closed", 344 | "timestamp": "2024-11-24T04:02:33Z", 345 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5154#issuecomment-2495791202", 346 | "title": "ISSUE pr_closed 11/24/2024, 4:02:33 AM -- [Bug]: FinishTool doesn't have a tool response", 347 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nReported [on slack](https://openhands-ai.slack.com/archives/C06R25BT5B2/p1732130085002229). Regression from function calling.\r\n\r\nFinishAction with `source=agent` is not the last thing that happens when running with UI or restoring session in CLI, but our backend doesn't give it a tool response. CodeAct \"forgets\" it, which makes other things not play nice...", 348 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5231" 349 | }, 350 | { 351 | "id": "pr-5226-2495596128", 352 | "type": "pr", 353 | "status": "failure", 354 | "timestamp": "2024-11-23T18:36:05Z", 355 | "url": "https://github.com/All-Hands-AI/OpenHands/pull/5226#issuecomment-2495609874", 356 | "title": "PR failure 11/23/2024, 6:36:05 PM -- Fix issue #5186: [Bug]: Fix up inline code styles in chat window", 357 | "description": "This pull request fixes #5186.\n\nThe issue has been successfully resolved. The AI agent made specific improvements to the inline code styling that directly address the reported concerns about font size and visual appearance:\n\n1. Font size was adjusted upward (from 85% to 90%) to address the sizing concern\n2. Background and text colors were modified using appropriate theme variables to improve visibility and contrast\n3. Added a border to make code blocks more distinct\n4. Fine-tuned the border radi..." 358 | }, 359 | { 360 | "id": "issue-5229-2495521927", 361 | "type": "issue", 362 | "status": "no_pr", 363 | "timestamp": "2024-11-23T15:59:49Z", 364 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5229#issuecomment-2495522602", 365 | "title": "ISSUE no_pr 11/23/2024, 3:59:49 PM -- [Documentation]: Micro-agents", 366 | "description": "**What problem or use case are you trying to solve?**\r\n\r\nCurrently in the `openhands/agenthub/codeact_agent` directory, we have an implementation of micro agents, but this is not documented.\r\n\r\nTo do so, we can:\r\n1. read the implementation of codeact agent\r\n2. read an example microagent in `openhands/agenthub/codeact_agent/micro/github.md`\r\n3. add documentation to `openhands/agenthub/codeact_agent/README.md`\r\n" 367 | }, 368 | { 369 | "id": "issue-5195-2495495143", 370 | "type": "issue", 371 | "status": "pr_closed", 372 | "timestamp": "2024-11-23T14:25:56Z", 373 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5195#issuecomment-2495496472", 374 | "title": "ISSUE pr_closed 11/23/2024, 2:25:56 PM -- [Resolver]: Adding unit tests to Github workflows", 375 | "description": "**Summary**\r\nI would like to add unit tests for Github workflows for `openhands-resolver`.\r\n\r\n**Motivation**\r\nThe workflow definitions are the entry points for `openhands-resolver`, making it the largest potential single point of failure. Currently making changes to the workflows needs to be manually tested in a separate repo. We've added many ways to trigger the workflows, so testing all of them manually tends to be tedious and often error prone. A way to do tests (at least a fraction of them) ...", 376 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5227" 377 | }, 378 | { 379 | "id": "issue-5186-2495495630", 380 | "type": "issue", 381 | "status": "pr_merged", 382 | "timestamp": "2024-11-23T14:25:51Z", 383 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5186#issuecomment-2495496450", 384 | "title": "ISSUE pr_merged 11/23/2024, 2:25:51 PM -- [Bug]: Fix up inline code styles in chat window", 385 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nThe font size for the code is a little bigger, and the spaces feel off. We should maybe change text color and background color too, to make it more apparent\r\n\r\n\"Screenshot\r\n\n\n### OpenHands Installation\n\napp.all-hands.dev\n\n### Op...", 386 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5226" 387 | }, 388 | { 389 | "id": "issue-5162-2489951095", 390 | "type": "issue", 391 | "status": "no_pr", 392 | "timestamp": "2024-11-21T02:46:16Z", 393 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5162#issuecomment-2489953661", 394 | "title": "ISSUE no_pr 11/21/2024, 2:46:16 AM -- docs: Improve GitHub token setup documentation in UI guide", 395 | "description": "The current documentation about GitHub token setup in the GUI mode guide is minimal and could be improved to help users better understand the process.\r\n\r\nNeeded improvements:\r\n\r\n1. Add step-by-step instructions for setting up a GitHub token locally:\r\n - How to access the token settings in the UI\r\n - Required token scopes and permissions\r\n - How to enter and save the token\r\n\r\n2. Add information about organizational token policies:\r\n - Note that organizational repositories may require addi..." 396 | }, 397 | { 398 | "id": "issue-5112-2484768514", 399 | "type": "issue", 400 | "status": "pr_closed", 401 | "timestamp": "2024-11-19T06:03:50Z", 402 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/5112#issuecomment-2484777977", 403 | "title": "ISSUE pr_closed 11/19/2024, 6:03:50 AM -- [Bug]: \"Push to GitHub\" shows up even if there's no repo connected", 404 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\n\"Screenshot\r\n\r\nRepro:\r\n* start a new project from scratch\r\n* push to github appears\r\n\r\nMy guess is it currently shows up if you're logged into GitHub, rather than if there's a repo connected to the current project. The latter is...", 405 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/5118" 406 | }, 407 | { 408 | "id": "issue-4828-2462874019", 409 | "type": "issue", 410 | "status": "pr_closed", 411 | "timestamp": "2024-11-07T17:58:14Z", 412 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/4828#issuecomment-2462891889", 413 | "title": "ISSUE pr_closed 11/7/2024, 5:58:14 PM -- [Bug]: Chat interface empty state flickers for a brief moment until messages load", 414 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nIf there are messages that are yet to be received by the socket, the empty state UI is shown for a brief moment, resulting in an unpleasant flicker\n\n### OpenHands Installation\n\nDocker command in README\n\n### OpenHands Version\n\nmain\n\n### Operating System\n\nNone\n\n### Logs, Errors, Screenshots, and Additional Context\n\nWe should probably wait until socket is green", 415 | "prUrl": "https://api.github.com/repos/All-Hands-AI/OpenHands/pulls/4831" 416 | }, 417 | { 418 | "id": "issue-4809-2461146053", 419 | "type": "issue", 420 | "status": "no_pr", 421 | "timestamp": "2024-11-07T01:50:59Z", 422 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/4809#issuecomment-2461152418", 423 | "title": "ISSUE no_pr 11/7/2024, 1:50:59 AM -- [Bug]: Model does not support image upload when using `litellm_proxy/`", 424 | "description": "### Is there an existing issue for the same bug?\r\n\r\n- [X] I have checked the existing issues.\r\n\r\n### Describe the bug and reproduction steps\r\n\r\nI got the following error when using model `litellm_proxy/claude-3-5-sonnet-20241022` through a LiteLLM proxy. It supposed to support vision inputs.\r\n\r\n\"image\"\r\n\r\nIn L345-L348 openhands/llm/llm.py, maybe we should also check for `litellm.support_vi..." 425 | }, 426 | { 427 | "id": "issue-4769-2458390348", 428 | "type": "issue", 429 | "status": "no_pr", 430 | "timestamp": "2024-11-05T23:24:47Z", 431 | "url": "https://github.com/All-Hands-AI/OpenHands/issues/4769#issuecomment-2458397249", 432 | "title": "ISSUE no_pr 11/5/2024, 11:24:47 PM -- [Bug]: Markdown numbering is rendered weird", 433 | "description": "### Is there an existing issue for the same bug?\n\n- [X] I have checked the existing issues.\n\n### Describe the bug and reproduction steps\n\nThe agent is likely sending separate messages with \"1.\" \"2.\" \"3.\"\r\n\r\nMarkdown is weird in that it will render any number as \"1.\", i.e.\r\n\r\n```md\r\n42.\r\n86.\r\n93.\r\n```\r\n\r\nwill render as\r\n```\r\n1.\r\n2.\r\n3.\r\n```\r\n\r\nI bet we can turn this behavior off\r\n\r\n