diff --git a/README.md b/README.md index 4d9ab12..ca46ace 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,138 @@ # GitHub Activity Report -This is a simple End of Day (EOD) update generator that helps you create a summary of your GitHub activity. Built using Next.js, this app provides a clean and organized way to track your daily progress on GitHub. - -![image](https://github.com/Ashesh3/github-activity/assets/3626859/c8aeff4d-e69e-44ed-91f1-f18e5b0bc36e) +A comprehensive tool for generating GitHub activity reports and downloading repository activity data with customizable filters. ## Features -- Generate EOD updates for your GitHub activity -- Track pull requests created, issues created, issues assigned, and commits made -- Quickly review your activity for the day or a specific time period +### 1. Activity Report Generator +- Generate EOD (End of Day) reports from your GitHub activity +- Track pull requests, issues, commits, and more +- Customizable date ranges and organization filters +- Export reports in markdown format + +### 2. Activity Downloader (New!) +- Download repository activity data with customizable filters +- Support for multiple activity types: Pull Requests, Issues, Commits, Releases, Discussions +- Filter by date range, authors, labels, and state +- Export in CSV, JSON, or Excel formats +- Real-time progress tracking during downloads ## Getting Started -To run the project locally, follow these steps: +### Prerequisites +- Node.js 18+ +- npm or yarn -1. Clone the repository: +### Installation +1. Clone the repository: ```bash -git clone https://github.com/Ashesh3/github-activity-report.git +git clone +cd github-activity ``` -2. Change the directory to the project folder: - -```bash -cd github-activity-report -``` - -3. Install the dependencies: - +2. Install dependencies: ```bash npm install ``` -4. Run the development server: - +3. Run the development server: ```bash npm run dev ``` -5. Open your browser and navigate to [http://localhost:3000 ↗](http://localhost:3000) to view the app. +4. Open [http://localhost:3000](http://localhost:3000) in your browser. ## Usage -1. Enter your GitHub username and the time range for which you want to generate the report. -2. Click the "Generate Report" button. -3. Review your GitHub activity, including pull requests, issues, and commits. -4. Edit the generated EOD update text as needed. -5. Share your EOD update with your team or save it for your records. +### Activity Report Generator + +1. **Enter GitHub Username**: Enter your GitHub username to fetch your activity +2. **Select Organization**: Choose an organization to filter activity (optional) +3. **Set Date Range**: Select the time period for your activity report +4. **Configure Settings**: Add your GitHub token for private repository access +5. **Generate Report**: Click "Fetch" to generate your activity timeline +6. **Customize EOD**: Select which activities to include in your EOD report +7. **Export**: Copy the generated markdown report + +### Activity Downloader + +1. **Enter Repository**: Enter a repository URL or owner/repo format (e.g., `ohcnetwork/care_fe`) +2. **Set Date Range**: Choose the time period for activity data +3. **Select Activity Types**: Choose which types of activity to download: + - Pull Requests + - Issues + - Commits + - Releases + - Discussions +4. **Apply Filters**: Filter by state, authors, and labels +5. **Choose Format**: Select CSV, JSON, or Excel format +6. **Download**: Click "Download Activity" to get your data + +## Supported Repository Formats + +The downloader supports various repository input formats: +- Full URL: `https://github.com/ohcnetwork/care_fe` +- Owner/Repo: `ohcnetwork/care_fe` +- Any public or private repository (with GitHub token) + +## GitHub Token Setup + +For enhanced functionality and access to private repositories: + +1. Go to [GitHub Settings > Developer settings > Personal access tokens](https://github.com/settings/tokens) +2. Generate a new token with appropriate permissions: + - `repo` (for private repositories) + - `read:org` (for organization access) +3. Copy the token and paste it in the settings modal +4. The token will be saved locally for future use + +## API Endpoints + +### `/api/github-activity` +POST endpoint for fetching repository activity data. + +**Request Body:** +```json +{ + "owner": "string", + "repo": "string", + "activityTypes": ["pull_requests", "issues", "commits"], + "startDate": "YYYY-MM-DD", + "endDate": "YYYY-MM-DD", + "state": "all|open|closed", + "authors": ["username1", "username2"], + "labels": ["label1", "label2"], + "githubToken": "optional_token" +} +``` + +**Response:** +```json +{ + "success": true, + "data": [...], + "total": 42, + "repository": "owner/repo" +} +``` + +## Technologies Used + +- **Next.js 14** - React framework +- **Ant Design** - UI components +- **Day.js** - Date manipulation +- **TypeScript** - Type safety +- **Tailwind CSS** - Styling ## Contributing -Contributions are welcome! Feel free to submit a pull request or open an issue for any bugs or feature requests. +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Submit a pull request ## License -This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for more information. \ No newline at end of file +This project is licensed under the MIT License. \ No newline at end of file diff --git a/src/app/api/github-activity/route.ts b/src/app/api/github-activity/route.ts new file mode 100644 index 0000000..42ddb61 --- /dev/null +++ b/src/app/api/github-activity/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; + +const execAsync = promisify(exec); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { owner, repo, startDate, endDate, state = "merged", githubToken } = body; + + if (!owner || !repo) { + return NextResponse.json({ error: "Owner and repository are required" }, { status: 400 }); + } + + if (!startDate || !endDate) { + return NextResponse.json({ error: "Start date and end date are required" }, { status: 400 }); + } + + // Set GitHub token if provided + if (githubToken) { + process.env.GITHUB_TOKEN = githubToken; + } + + // Try GitHub CLI first, then fallback to GitHub API + let data = []; + let useFallback = false; + + try { + // First, let's test if we can access the repository + console.log("Testing repository access..."); + const testCommand = `gh repo view ${owner}/${repo} --json name,description`; + + try { + await execAsync(testCommand); + console.log("Repository access successful"); + } catch (testError: any) { + console.error("Repository access failed:", testError.message); + useFallback = true; + } + + if (!useFallback) { + // Construct the GitHub CLI command - simplified version + const command = `gh pr list --repo ${owner}/${repo} --state ${state} --json number,title,state,author,createdAt,mergedAt,labels,assignees,url --limit 100`; + + console.log("Executing command:", command); + + // Add timeout to prevent hanging + const { stdout, stderr } = (await Promise.race([ + execAsync(command), + new Promise((_, reject) => setTimeout(() => reject(new Error("Command timeout after 30 seconds")), 30000)), + ])) as { stdout: string; stderr: string }; + + console.log("Command stdout length:", stdout.length); + console.log("Command stderr:", stderr); + + if (stderr && !stderr.includes("warning")) { + console.warn("GitHub CLI stderr:", stderr); + } + + if (stdout.trim()) { + try { + data = JSON.parse(stdout); + console.log("Successfully parsed JSON, found", data.length, "pull requests"); + } catch (parseError) { + console.error("Failed to parse JSON output:", parseError); + console.log("Raw stdout (first 500 chars):", stdout.substring(0, 500)); + useFallback = true; + } + } else { + console.log("No stdout received from command"); + useFallback = true; + } + } + } catch (execError: any) { + console.error("GitHub CLI execution error:", execError); + useFallback = true; + } + + // Fallback to GitHub API if CLI failed + if (useFallback || data.length === 0) { + console.log("Using GitHub API fallback..."); + + const headers: { [key: string]: string } = { + Accept: "application/vnd.github.v3+json", + }; + + if (githubToken) { + headers["Authorization"] = `token ${githubToken}`; + } + + const url = `https://api.github.com/repos/${owner}/${repo}/pulls`; + const params = new URLSearchParams(); + params.set("state", state === "merged" ? "closed" : state); + params.set("per_page", "100"); + + const response = await fetch(`${url}?${params.toString()}`, { headers }); + + if (!response.ok) { + throw new Error(`GitHub API Error: ${response.status} ${response.statusText}`); + } + + const apiData = await response.json(); + + // Filter for merged PRs if state is "merged" + if (state === "merged") { + data = apiData.filter((item: any) => item.merged_at != null); + } else { + data = apiData; + } + + console.log("GitHub API fallback found", data.length, "pull requests"); + } + + // Filter by date range if needed + let filteredData = data; + if (startDate && endDate) { + filteredData = data.filter((item: any) => { + if (!item.mergedAt && !item.merged_at) return false; + const mergedDate = new Date(item.mergedAt || item.merged_at); + const start = new Date(startDate); + const end = new Date(endDate); + return mergedDate >= start && mergedDate <= end; + }); + console.log("Filtered to", filteredData.length, "PRs in date range"); + } + + // Transform the data to match the expected format + const transformedData = filteredData.map((item: any) => ({ + number: item.number, + title: item.title, + type: "pull_requests", + state: item.state, + author: item.author?.login || item.user?.login || "", + created_at: item.createdAt || item.created_at, + merged_at: item.mergedAt || item.merged_at, + labels: item.labels?.map((l: any) => l.name).join(", ") || "", + assignees: item.assignees?.map((a: any) => a.login).join(", ") || "", + html_url: item.url || item.html_url, + })); + + return NextResponse.json({ + success: true, + data: transformedData, + total: transformedData.length, + repository: `${owner}/${repo}`, + }); + } catch (error: any) { + console.error("GitHub Activity API Error:", error); + return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 }); + } +} diff --git a/src/app/download/page.tsx b/src/app/download/page.tsx new file mode 100644 index 0000000..ff9934e --- /dev/null +++ b/src/app/download/page.tsx @@ -0,0 +1,432 @@ +"use client"; + +import React, { useState } from "react"; +import { Button, Card, Input, notification, Typography, Form, Radio, Alert, Checkbox, DatePicker } from "antd"; +import { DownloadOutlined, CopyOutlined } from "@ant-design/icons"; +import Title from "antd/es/typography/Title"; +import Paragraph from "antd/es/typography/Paragraph"; +import { useRouter } from "next/navigation"; +import type { Dayjs } from "dayjs"; + +const { Text } = Typography; +const { TextArea } = Input; +const { RangePicker } = DatePicker; + +type NotificationType = "success" | "info" | "warning" | "error"; + +const globalStyles = ` + .notification-compact { + padding: 8px 12px !important; + } + .notification-compact .ant-notification-notice-message { + margin-bottom: 0 !important; + font-size: 12px !important; + } + .notification-compact .ant-notification-notice-description { + font-size: 10px !important; + } + @media (max-width: 768px) { + .ant-notification-notice { + margin-right: 0 !important; + margin-left: 0 !important; + } + } +`; + +interface ConverterState { + jsonInput: string; + csvOutput: string; + isConverting: boolean; +} + +interface CommandGenerator { + repository: string; + state: "merged" | "open" | "closed"; + dateRange: [Dayjs, Dayjs] | null; + selectedFields: string[]; + generatedCommand: string; +} + +const fieldOptions = [ + { label: "Number", value: "number" }, + { label: "Title", value: "title" }, + { label: "State", value: "state" }, + { label: "Author", value: "author" }, + { label: "Created At", value: "createdAt" }, + { label: "Merged At", value: "mergedAt" }, + { label: "Labels", value: "labels" }, + { label: "Assignees", value: "assignees" }, + { label: "URL", value: "url" }, +]; + +export default function DownloadPage() { + const [api, contextHolder] = notification.useNotification(); + const router = useRouter(); + + const [converter, setConverter] = useState({ + jsonInput: "", + csvOutput: "", + isConverting: false, + }); + + const [commandGenerator, setCommandGenerator] = useState({ + repository: "", + state: "merged", + dateRange: null, + selectedFields: ["number", "title", "state", "author", "createdAt", "mergedAt", "labels", "assignees", "url"], + generatedCommand: "", + }); + + const notify = (type: NotificationType, message: string, description: string) => { + api[type]({ + message, + description, + duration: 3, + className: "notification-compact", + style: { + width: "auto", + minWidth: "250px", + maxWidth: "90vw", + }, + }); + }; + + const parseRepositoryUrl = (url: string) => { + const patterns = [/github\.com\/([^/]+)\/([^/?]+)/, /^([^/]+)\/([^/]+)$/]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return { + owner: match[1], + repo: match[2].replace(".git", ""), + }; + } + } + return null; + }; + + const generateCommand = () => { + if (!commandGenerator.repository) { + notify("error", "Repository Required", "Please enter a repository"); + return; + } + + const parsed = parseRepositoryUrl(commandGenerator.repository); + if (!parsed) { + notify("error", "Invalid Repository", "Please enter a valid repository URL or owner/repo format"); + return; + } + + let command = `gh pr list --repo ${parsed.owner}/${parsed.repo} --state ${commandGenerator.state}`; + + // Add JSON fields + const jsonFields = commandGenerator.selectedFields.join(","); + command += ` --json "${jsonFields}"`; + + // Add date range if selected + if (commandGenerator.dateRange) { + const startDate = commandGenerator.dateRange[0].format("YYYY-MM-DD"); + const endDate = commandGenerator.dateRange[1].format("YYYY-MM-DD"); + command += ` --search "is:pr merged:${startDate}..${endDate}"`; + } + + command += ` --limit 300`; + + setCommandGenerator((prev) => ({ ...prev, generatedCommand: command })); + }; + + const copyCommand = () => { + if (commandGenerator.generatedCommand) { + navigator.clipboard.writeText(commandGenerator.generatedCommand); + notify("success", "Command Copied", "GitHub CLI command copied to clipboard"); + } + }; + + const convertJsonToCsv = (jsonData: any[], selectedFields: string[]) => { + if (jsonData.length === 0) return ""; + + // Always include slno as first column, then selected fields + const headers = ["slno", ...selectedFields]; + const csvRows = [headers.join(",")]; + + jsonData.forEach((item, index) => { + const row = headers.map((header) => { + let value = ""; + if (header === "slno") { + value = (index + 1).toString(); + } else if (header === "author") { + // Extract author name from author object + if (item.author && typeof item.author === "object") { + value = item.author.name || item.author.login || ""; + } else { + value = item.author || ""; + } + } else { + value = item[header]; + if (value === null || value === undefined) value = ""; + if (Array.isArray(value)) value = value.join(", "); + if (typeof value === "object") value = JSON.stringify(value); + } + return `"${String(value).replace(/"/g, '""')}"`; + }); + csvRows.push(row.join(",")); + }); + + return csvRows.join("\n"); + }; + + const handleConvert = () => { + if (!converter.jsonInput.trim()) { + notify("error", "Input Required", "Please paste your JSON data"); + return; + } + + setConverter({ ...converter, isConverting: true }); + + try { + const jsonData = JSON.parse(converter.jsonInput); + + if (!Array.isArray(jsonData)) { + notify("error", "Invalid JSON", "Please provide a JSON array"); + setConverter({ ...converter, isConverting: false }); + return; + } + + // Auto-detect fields from the first item + const firstItem = jsonData[0] || {}; + const availableFields = Object.keys(firstItem).filter((key) => + fieldOptions.some((option) => option.value === key) + ); + + // Use the same fields selected in the command generator + const selectedFields = + commandGenerator.selectedFields.length > 0 ? commandGenerator.selectedFields : availableFields; + + const output = convertJsonToCsv(jsonData, selectedFields); + + setConverter((prev) => ({ + ...prev, + csvOutput: output, + isConverting: false, + })); + + notify("success", "Conversion Complete", `Converted ${jsonData.length} items`); + } catch (error: any) { + notify("error", "Conversion Failed", error.message); + setConverter((prev) => ({ ...prev, isConverting: false })); + } + }; + + const handleDownload = () => { + if (!converter.csvOutput) { + notify("error", "No Output", "Please convert data first"); + return; + } + + const filename = `converted-data.csv`; + const blob = new Blob([converter.csvOutput], { type: "text/plain" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + notify("success", "Download Complete", `Downloaded ${filename}`); + }; + + const handleClear = () => { + setConverter({ + jsonInput: "", + csvOutput: "", + isConverting: false, + }); + }; + + return ( + <> + + {contextHolder} +
+ +
+
+ + <DownloadOutlined className="mr-3 text-blue-500" /> + JSON to CSV Converter + + Convert GitHub CLI JSON output to CSV format +
+ +
+ + + + {/* Command Generator Section */} +
+ + 🔧 Generate GitHub CLI Command + + +
+ {/* Repository Input */} +
+ Repository} required> + setCommandGenerator({ ...commandGenerator, repository: e.target.value })} + status={ + commandGenerator.repository && !parseRepositoryUrl(commandGenerator.repository) + ? "error" + : undefined + } + /> + +
+ + {/* State Selection */} +
+ State}> + setCommandGenerator({ ...commandGenerator, state: e.target.value })} + > + Merged + Open + Closed + + +
+ + {/* Date Range */} +
+ Date Range (Optional)}> + + setCommandGenerator({ ...commandGenerator, dateRange: dates as [Dayjs, Dayjs] | null }) + } + className="w-full" + /> + +
+
+ + {/* Command Fields Selection */} +
+ + + JSON Fields to Include + +
+ + setCommandGenerator({ ...commandGenerator, selectedFields: values as string[] }) + } + className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3" + /> +
+
+
+ {/* Generate Button */} + + +
+ + {/* Generated Command */} + {commandGenerator.generatedCommand && ( +
+
+ + Generated Command: + + +
+