Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 82 additions & 26 deletions src/app/action-required/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import {
Table,
TableBody,
Expand All @@ -23,7 +24,6 @@ import {
Zap,
ExternalLink,
RefreshCw,
Star,
LucideIcon,
} from "lucide-react";
import { useActionItemsStore } from "@/stores";
Expand All @@ -38,16 +38,34 @@ interface ActionItem {
url?: string;
repo: string;
type: string;
author?: string;
author: {
login: string;
avatarUrl: string;
};
labels: Array<{ name: string; color?: string }>;
priority: "urgent" | "high" | "medium" | "low";
daysOld?: number;
updatedAt: string;
comments?: number;
stars?: number;
}

const VALID_TABS = ["assigned", "mentions", "stale"] as const;
type ValidTab = (typeof VALID_TABS)[number];

function formatTimeAgo(dateString: string): string {
const now = new Date();
const past = new Date(dateString);
const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);

if (diffInSeconds < 60) return "just now";
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 604800)}w ago`;
return `${Math.floor(diffInSeconds / 2592000)}mo ago`;
}

function ActionRequiredContent() {
const {
assignedItems,
Expand Down Expand Up @@ -144,12 +162,13 @@ function ActionRequiredContent() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[35%]">Title / Repository</TableHead>
<TableHead className="w-[12%]">Priority</TableHead>
<TableHead className="w-[12%]">Activity</TableHead>
<TableHead className="w-[13%]">Repo Popularity</TableHead>
<TableHead className="w-[13%]">Type</TableHead>
<TableHead className="w-[15%]">Actions</TableHead>
<TableHead className="w-[30%]">Title / Repository</TableHead>
<TableHead className="w-[10%]">Author</TableHead>
<TableHead className="w-[18%]">Labels</TableHead>
<TableHead className="w-[10%]">Priority</TableHead>
<TableHead className="w-[8%]">Activity</TableHead>
<TableHead className="w-[10%]">Updated</TableHead>
<TableHead className="w-[14%]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -162,16 +181,22 @@ function ActionRequiredContent() {
</div>
</TableCell>
<TableCell>
<div className="w-16 h-6 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
<div className="w-8 h-8 bg-gray-300 dark:bg-gray-600 rounded-full animate-pulse" />
</TableCell>
<TableCell>
<div className="w-12 h-4 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
<div className="flex gap-1">
<div className="w-16 h-6 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
<div className="w-16 h-6 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
</div>
</TableCell>
<TableCell>
<div className="w-16 h-6 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="w-12 h-4 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="w-16 h-6 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
<div className="w-16 h-4 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
</TableCell>
<TableCell>
<div className="w-8 h-8 bg-gray-300 dark:bg-gray-600 rounded animate-pulse" />
Expand Down Expand Up @@ -221,12 +246,13 @@ function ActionRequiredContent() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[35%]">Title / Repository</TableHead>
<TableHead className="w-[12%]">Priority</TableHead>
<TableHead className="w-[12%]">Activity</TableHead>
<TableHead className="w-[13%]">Repo Popularity</TableHead>
<TableHead className="w-[13%]">Type</TableHead>
<TableHead className="w-[15%]">Actions</TableHead>
<TableHead className="w-[30%]">Title / Repository</TableHead>
<TableHead className="w-[10%]">Author</TableHead>
<TableHead className="w-[18%]">Labels</TableHead>
<TableHead className="w-[10%]">Priority</TableHead>
<TableHead className="w-[8%]">Activity</TableHead>
<TableHead className="w-[10%]">Updated</TableHead>
<TableHead className="w-[14%]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
Expand All @@ -253,11 +279,47 @@ function ActionRequiredContent() {
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{item.repo}
{item.author && ` • ${item.author}`}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="w-8 h-8">
<AvatarImage src={item.author.avatarUrl} alt={item.author.login} />
<AvatarFallback>
{item.author.login.substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{item.labels.slice(0, 3).map((label, idx) => (
<Badge
key={idx}
variant="outline"
className="text-xs"
style={
label.color
? {
borderColor: `#${label.color}`,
backgroundColor: `#${label.color}20`,
color: `#${label.color}`,
}
: undefined
}
>
{label.name}
</Badge>
))}
{item.labels.length > 3 && (
<Badge variant="outline" className="text-xs">
+{item.labels.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
Expand All @@ -281,16 +343,10 @@ function ActionRequiredContent() {
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
<Star className="w-4 h-4 text-yellow-500" />
<span>{item.stars || 0}</span>
<div className="text-sm text-gray-500 dark:text-gray-400">
{formatTimeAgo(item.updatedAt)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{item.type === "pullRequest" ? "PR" : "Issue"}
</Badge>
</TableCell>
<TableCell>
<AddToKanbanButton item={item as StoreActionItem} />
</TableCell>
Expand Down
14 changes: 10 additions & 4 deletions src/components/quick-wins/hooks/useQuickWins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ export function useQuickWins() {
url: issue.url,
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.author.login,
labels: issue.labels.map((l) => l.name),
author: {
login: issue.author.login,
avatarUrl: issue.author.avatar_url,
},
labels: issue.labels.map((l) => ({ name: l.name, color: l.color })),
daysOld: Math.floor(
(Date.now() - new Date(issue.created_at).getTime()) /
(1000 * 60 * 60 * 24)
Expand All @@ -69,8 +72,11 @@ export function useQuickWins() {
url: issue.url,
createdAt: issue.created_at,
updatedAt: issue.updated_at,
author: issue.author.login,
labels: issue.labels.map((l) => l.name),
author: {
login: issue.author.login,
avatarUrl: issue.author.avatar_url,
},
labels: issue.labels.map((l) => ({ name: l.name, color: l.color })),
daysOld: Math.floor(
(Date.now() - new Date(issue.created_at).getTime()) /
(1000 * 60 * 60 * 24)
Expand Down
45 changes: 38 additions & 7 deletions src/lib/api/github-graphql-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ interface GitHubActionItem {
url: string;
repo: string;
type: "issue" | "pullRequest";
author: string;
author: {
login: string;
avatarUrl: string;
};
labels: Array<{ name: string; color?: string }>;
priority: "urgent" | "high" | "medium" | "low";
daysOld: number;
createdAt: string;
Expand Down Expand Up @@ -107,9 +111,10 @@ interface ActionItem {
};
author: {
login: string;
avatarUrl: string;
} | null;
labels: {
nodes: Array<{ name: string }>;
nodes: Array<{ name: string; color?: string }>;
};
comments: {
totalCount: number;
Expand All @@ -129,12 +134,13 @@ interface PullRequest {
};
author: {
login: string;
avatarUrl: string;
} | null;
assignees: {
nodes: Array<{ login: string }>;
};
labels: {
nodes: Array<{ name: string }>;
nodes: Array<{ name: string; color?: string }>;
};
comments: {
totalCount: number;
Expand Down Expand Up @@ -499,10 +505,12 @@ class GitHubGraphQLClient {
}
author {
login
avatarUrl
}
labels(first: 10) {
nodes {
name
color
}
}
comments {
Expand All @@ -525,6 +533,7 @@ class GitHubGraphQLClient {
}
author {
login
avatarUrl
}
assignees(first: 10) {
nodes {
Expand All @@ -534,6 +543,7 @@ class GitHubGraphQLClient {
labels(first: 10) {
nodes {
name
color
}
}
comments {
Expand Down Expand Up @@ -562,10 +572,12 @@ class GitHubGraphQLClient {
}
author {
login
avatarUrl
}
labels(first: 10) {
nodes {
name
color
}
}
comments {
Expand Down Expand Up @@ -595,10 +607,12 @@ class GitHubGraphQLClient {
}
author {
login
avatarUrl
}
labels(first: 10) {
nodes {
name
color
}
}
comments {
Expand All @@ -618,10 +632,12 @@ class GitHubGraphQLClient {
}
author {
login
avatarUrl
}
labels(first: 10) {
nodes {
name
color
}
}
comments {
Expand Down Expand Up @@ -659,10 +675,12 @@ class GitHubGraphQLClient {
}
author {
login
avatarUrl
}
labels(first: 10) {
nodes {
name
color
}
}
comments {
Expand Down Expand Up @@ -773,7 +791,11 @@ class GitHubGraphQLClient {
(Date.now() - new Date(item.createdAt).getTime()) / (1000 * 60 * 60 * 24)
);
const labels =
item.labels?.nodes?.map((l: { name: string }) => l.name) || [];
item.labels?.nodes?.map((l: { name: string; color?: string }) => ({
name: l.name,
color: l.color,
})) || [];
const labelNames = labels.map((l) => l.name);

const isPR = this.isPullRequest(item);

Expand All @@ -783,8 +805,12 @@ class GitHubGraphQLClient {
url: item.url,
repo: item.repository.nameWithOwner,
type: isPR ? "pullRequest" : "issue",
author: item.author?.login || "unknown",
priority: this.calculateActionPriority(labels, daysOld),
author: {
login: item.author?.login || "unknown",
avatarUrl: item.author?.avatarUrl || "",
},
labels,
priority: this.calculateActionPriority(labelNames, daysOld, mentionType),
daysOld,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
Expand All @@ -796,8 +822,13 @@ class GitHubGraphQLClient {

private calculateActionPriority(
labels: string[],
daysOld: number
daysOld: number,
mentionType?: "mention" | "review_request" | "comment"
): "urgent" | "high" | "medium" | "low" {
if (mentionType === "review_request") {
return "urgent";
}

const lowerLabels = labels.map((l) => l.toLowerCase());

if (
Expand Down
6 changes: 5 additions & 1 deletion src/stores/actionItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export interface ActionItem {
createdAt: string;
updatedAt: string;
assignee?: string;
author: string;
author: {
login: string;
avatarUrl: string;
};
labels: Array<{ name: string; color?: string }>;
daysOld: number;
Comment on lines +16 to 21
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Persisted ActionItem shape is not migrated (risk of crashes for existing users)

You’ve upgraded ActionItem to:

  • author: { login: string; avatarUrl: string }
  • labels: Array<{ name: string; color?: string }> (required)

but the store still persists assignedItems / mentionItems / staleItems under the same "githubmon-action-items" key with no rehydrate migration. Existing installations may have cached items where:

  • author is still a plain string.
  • labels is missing or in an older shape.

The new UI (e.g., item.author.login.substring(...), label rendering) assumes the new structure and can throw at runtime on first render before refreshData overwrites stale entries.

Consider one of:

  • Bump the storage key name to force a fresh cache.
  • Add an onRehydrateStorage (or equivalent) to normalize old entries into the new { author, labels } shape.
  • Make author and labels backward‑compatible unions and normalize them lazily on read.

I’d treat this as important to fix before shipping since it affects existing users’ first render after upgrade.


I need to verify the persistence configuration and interface changes. Let me investigate the codebase.


🏁 Script executed:

#!/bin/bash
# Check the ActionItem interface definition and persist configuration
cat src/stores/actionItems.ts

Length of output: 12141


🏁 Script executed:

#!/bin/bash
# Search for onRehydrateStorage usage in the store
rg -n "onRehydrateStorage" src/stores/actionItems.ts

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for persist key name
rg -n "githubmon-action-items" src/

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Find UI files consuming author.login to verify the assumption
rg -nC3 "author\.login|item\.author" src/pages/ src/components/

Length of output: 2334


Let me verify if there's any defensive coding or migration logic elsewhere:


🏁 Script executed:

#!/bin/bash
# Search for defensive checks around author access (optional chaining, type guards)
rg -nC2 "author\?" src/components/ src/lib/

Length of output: 2288


🏁 Script executed:

#!/bin/bash
# Search for migration logic or store initialization
rg -n "migrate|rehydrate|onRehydrate" src/stores/ src/lib/

Length of output: 777


🏁 Script executed:

#!/bin/bash
# Check if other stores have migration patterns
grep -r "onRehydrateStorage" src/stores/

Length of output: 65


🏁 Script executed:

#!/bin/bash
# Search for any shape normalization or type coercion on author/labels
rg -nC3 "typeof.*author|Array.isArray.*labels" src/

Length of output: 0


ActionItem persistence schema mismatch confirmed—no migration for breaking changes

Verified: The store persists assignedItems, mentionItems, and staleItems to "githubmon-action-items" with:

  • author changed from string to required { login: string; avatarUrl: string }
  • labels now required as Array<{ name: string; color?: string }>

No onRehydrateStorage or migration present in the persist config. The UI (e.g., author.login.charAt(0) in columns.tsx) directly accesses the new structure without defensive checks.

Existing installations have cached items where author is still a string or labels is missing. On first render after upgrade (before refreshData completes), components will crash trying to call .charAt() on a string or undefined.

Fix before shipping—existing users are at risk. Your suggested mitigations (storage key bump, onRehydrateStorage migration, or backward-compatible unions) are sound; the codebase already uses onRehydrateStorage in kanban.ts and migration patterns in auth.ts, so precedent exists.

🤖 Prompt for AI Agents
In src/stores/actionItems.ts around lines 16 to 21, persisted action items
changed shape (author was string, now object with login/avatarUrl; labels became
required array) but no migration exists; add an onRehydrateStorage migration (or
bump the storage key) to convert legacy entries: when rehydrating, detect author
as string and replace with { login: oldString, avatarUrl: '' } (or derive avatar
if available), ensure labels is at least an empty array and normalize any label
strings into { name: string, color?: string }, and keep daysOld numeric;
implement these transforms before the store is used so components calling
author.login.charAt(0) or iterating labels cannot crash. Ensure the migration is
idempotent and preserves existing new-shape records.

comments?: number;
stars?: number;
Expand Down
Loading