Building Andúril: A GitHub-Focused Productivity Tool for Developers

February 20, 2024
8 min read
👩‍💻 Code Hard, Nap Harder, Dream in Binary
Because even our subconscious runs on zeros and ones. 😄🌙💤

As a developer working on multiple projects, I found myself constantly switching between GitHub issues, pull requests, and project boards. While tools like ZenHub and Zerocracy exist, I wanted to understand how GitHub-focused productivity tools work under the hood.

That curiosity led me to build Andúril - a personal productivity tool designed to streamline GitHub workflows. Named after the legendary sword from Lord of the Rings, it's built to be a reliable companion for developer productivity within the GitHub ecosystem.

The Problem I Set Out to Solve

This wasn't about building the next big productivity platform - it was about understanding developer workflow optimization:

  • How do you integrate deeply with GitHub's API?
  • What makes a productivity tool feel native to a developer's workflow?
  • How do you visualize complex project relationships?
  • What are the technical challenges of real-time GitHub synchronization?

The goal was simple: Build a GitHub-focused productivity tool to understand how developer tools actually work.

Technical Architecture Decisions

Why TypeScript and Next.js?

I chose TypeScript with Next.js for several reasons:

  • Type safety - Critical when working with complex GitHub API responses
  • API routes - Perfect for GitHub webhook handling and API proxying
  • Server-side rendering - Better performance for dashboard views
  • Rich ecosystem - Excellent GitHub integration libraries

The GitHub Integration Challenge

The most complex part was creating seamless GitHub synchronization:

class GitHubSyncManager {
  private octokit: Octokit;
  private webhookQueue: Queue<GitHubWebhookEvent>;
  
  async syncRepository(repo: Repository) {
    try {
      const [issues, pullRequests, projects] = await Promise.all([
        this.octokit.issues.listForRepo({
          owner: repo.owner,
          repo: repo.name,
          state: 'all'
        }),
        this.octokit.pulls.list({
          owner: repo.owner,
          repo: repo.name,
          state: 'all'
        }),
        this.octokit.projects.listForRepo({
          owner: repo.owner,
          repo: repo.name
        })
      ]);
      
      await this.updateLocalDatabase({
        issues: issues.data,
        pullRequests: pullRequests.data,
        projects: projects.data
      });
      
    } catch (error) {
      this.handleSyncError(error);
    }
  }
  
  async handleWebhook(event: GitHubWebhookEvent) {
    switch (event.action) {
      case 'opened':
      case 'closed':
      case 'edited':
        await this.updateIssueStatus(event.issue);
        break;
      case 'synchronize':
        await this.updatePullRequest(event.pull_request);
        break;
    }
  }
}

Database Design for GitHub Data

I used PostgreSQL with Prisma to model GitHub relationships:

model Repository {
  id          String   @id
  name        String
  owner       String
  description String?
  language    String?
  issues      Issue[]
  pullRequests PullRequest[]
  projects    Project[]
  
  @@unique([owner, name])
}

model Issue {
  id           Int          @id
  number       Int
  title        String
  body         String?
  state        IssueState
  labels       Label[]
  assignees    User[]
  repository   Repository   @relation(fields: [repositoryId], references: [id])
  repositoryId String
  
  @@unique([repositoryId, number])
}

model PullRequest {
  id           Int          @id
  number       Int
  title        String
  body         String?
  state        PRState
  isDraft      Boolean
  repository   Repository   @relation(fields: [repositoryId], references: [id])
  repositoryId String
  
  @@unique([repositoryId, number])
}

Design Philosophy: Developer-First Interface

Information Architecture

I designed Andúril with developer workflows in mind:

  • Command palette for quick actions (Cmd+K)
  • Keyboard-first navigation throughout the interface
  • Real-time updates via WebSocket connections
  • Contextual views that adapt to current work

Key Interface Components

// Command palette for quick GitHub actions
const GitHubCommandPalette = () => {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<CommandResult[]>([]);
  
  const commands = useMemo(() => [
    {
      id: 'create-issue',
      title: 'Create Issue',
      action: () => router.push('/issues/new'),
      icon: <PlusIcon />
    },
    {
      id: 'open-pr',
      title: 'Create Pull Request',
      action: () => createPullRequest(),
      icon: <GitPullRequestIcon />
    },
    {
      id: 'switch-repo',
      title: 'Switch Repository',
      action: (repo) => switchRepository(repo),
      icon: <RepoIcon />
    }
  ], []);
  
  useEffect(() => {
    const filtered = commands.filter(cmd => 
      cmd.title.toLowerCase().includes(query.toLowerCase())
    );
    setResults(filtered);
  }, [query, commands]);
  
  return (
    <CommandMenu open={isOpen} onOpenChange={setIsOpen}>
      <CommandInput 
        placeholder="Search repositories, issues, PRs..."
        value={query}
        onValueChange={setQuery}
      />
      <CommandList>
        {results.map(result => (
          <CommandItem 
            key={result.id}
            onSelect={result.action}
          >
            {result.icon}
            {result.title}
          </CommandItem>
        ))}
      </CommandList>
    </CommandMenu>
  );
};

The Challenges I Overcame

1. GitHub API Rate Limiting

Managing GitHub's API limits while keeping data fresh:

class RateLimitManager {
  private requestQueue: Queue<GitHubRequest>;
  private rateLimitStatus: RateLimitStatus;
  
  async executeRequest<T>(request: () => Promise<T>): Promise<T> {
    await this.waitForRateLimit();
    
    try {
      const result = await request();
      this.updateRateLimitStatus();
      return result;
    } catch (error) {
      if (error.status === 403) {
        // Rate limit exceeded
        await this.handleRateLimitError(error);
        return this.executeRequest(request);
      }
      throw error;
    }
  }
  
  private async waitForRateLimit() {
    if (this.rateLimitStatus.remaining === 0) {
      const waitTime = this.rateLimitStatus.resetTime - Date.now();
      if (waitTime > 0) {
        await new Promise(resolve => setTimeout(resolve, waitTime));
      }
    }
  }
}

2. Real-Time Synchronization

Keeping local data in sync with GitHub changes:

class WebSocketManager {
  private ws: WebSocket;
  private reconnectAttempts = 0;
  
  connect() {
    this.ws = new WebSocket(process.env.WEBSOCKET_URL);
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.handleGitHubUpdate(data);
    };
    
    this.ws.onclose = () => {
      if (this.reconnectAttempts < 5) {
        setTimeout(() => {
          this.reconnectAttempts++;
          this.connect();
        }, Math.pow(2, this.reconnectAttempts) * 1000);
      }
    };
  }
  
  private handleGitHubUpdate(update: GitHubUpdate) {
    switch (update.type) {
      case 'issue_updated':
        this.updateIssueInUI(update.payload);
        break;
      case 'pr_merged':
        this.updatePRStatus(update.payload);
        break;
      case 'new_commit':
        this.refreshCommitHistory(update.payload.sha);
        break;
    }
  }
}

3. Complex State Management

Managing interconnected GitHub data efficiently:

// Zustand store for GitHub data
interface GitHubStore {
  repositories: Repository[];
  currentRepo: Repository | null;
  issues: Issue[];
  pullRequests: PullRequest[];
  
  // Actions
  setCurrentRepo: (repo: Repository) => void;
  updateIssue: (issue: Issue) => void;
  addPullRequest: (pr: PullRequest) => void;
  syncRepository: (repoId: string) => Promise<void>;
}

const useGitHubStore = create<GitHubStore>((set, get) => ({
  repositories: [],
  currentRepo: null,
  issues: [],
  pullRequests: [],
  
  setCurrentRepo: (repo) => {
    set({ currentRepo: repo });
    // Auto-sync when switching repos
    get().syncRepository(repo.id);
  },
  
  updateIssue: (updatedIssue) => {
    set((state) => ({
      issues: state.issues.map(issue => 
        issue.id === updatedIssue.id ? updatedIssue : issue
      )
    }));
  },
  
  syncRepository: async (repoId) => {
    const syncManager = new GitHubSyncManager();
    await syncManager.syncRepository(repoId);
    // Update store with fresh data
  }
}));

Key Features That Make Andúril Special

1. Intelligent Issue Management

GitHub issues with enhanced workflow automation:

class IssueWorkflowManager {
  async autoAssignReviewer(issue: Issue): Promise<User | null> {
    // Analyze code changes to suggest best reviewer
    const changedFiles = await this.getChangedFiles(issue);
    const codeOwners = await this.getCodeOwners(changedFiles);
    
    // Find most available team member with relevant expertise
    const reviewers = await this.findAvailableReviewers(codeOwners);
    return this.selectBestReviewer(reviewers, issue.labels);
  }
  
  async createIssueTemplate(type: IssueType): Promise<IssueTemplate> {
    const templates = {
      'bug': {
        title: '🐛 Bug Report: ',
        body: `
## Description
Brief description of the bug

## Steps to Reproduce
1. 
2. 
3. 

## Expected Behavior
What should happen

## Actual Behavior  
What actually happens

## Environment
- OS: 
- Browser: 
- Version: 
        `
      },
      'feature': {
        title: '✨ Feature Request: ',
        body: `
## Summary
Brief description of the feature

## Motivation  
Why is this feature needed?

## Detailed Design
How should this work?

## Alternatives Considered
What other approaches did you consider?
        `
      }
    };
    
    return templates[type];
  }
}

2. Advanced Pull Request Analytics

Insights into PR patterns and team velocity:

class PRAnalytics {
  async calculateTeamVelocity(team: string[], timeframe: number): Promise<VelocityMetrics> {
    const prs = await this.getPullRequestsForTimeframe(team, timeframe);
    
    return {
      averageReviewTime: this.calculateAverageReviewTime(prs),
      mergeSuccess: this.calculateMergeSuccessRate(prs),
      codeChurnRate: this.calculateCodeChurn(prs),
      collaborationScore: this.calculateCollaborationScore(prs)
    };
  }
  
  async generateReviewReport(pullRequest: PullRequest): Promise<ReviewReport> {
    const complexity = await this.analyzeCodeComplexity(pullRequest);
    const testCoverage = await this.calculateTestCoverage(pullRequest);
    const dependencies = await this.analyzeDependencyChanges(pullRequest);
    
    return {
      riskLevel: this.calculateRiskLevel(complexity, testCoverage, dependencies),
      suggestedReviewers: await this.suggestReviewers(pullRequest),
      estimatedReviewTime: this.estimateReviewTime(complexity),
      automatedChecks: await this.runAutomatedChecks(pullRequest)
    };
  }
}

3. Project Workflow Automation

Custom GitHub Actions integration and workflow triggers:

class WorkflowAutomation {
  async setupProjectAutomation(project: Project): Promise<void> {
    const workflows = [
      {
        name: 'Auto-assign Issues',
        trigger: 'issues.opened',
        action: async (issue) => {
          const assignee = await this.findBestAssignee(issue);
          await this.assignIssue(issue, assignee);
        }
      },
      {
        name: 'PR Size Checker',
        trigger: 'pull_request.opened',
        action: async (pr) => {
          const size = await this.calculatePRSize(pr);
          if (size > 500) {
            await this.addLabel(pr, 'large-pr');
            await this.requestReviewBreakdown(pr);
          }
        }
      },
      {
        name: 'Stale Issue Cleanup',
        trigger: 'schedule.daily',
        action: async () => {
          const staleIssues = await this.findStaleIssues();
          for (const issue of staleIssues) {
            await this.addStaleComment(issue);
          }
        }
      }
    ];
    
    await this.registerWorkflows(project, workflows);
  }
}

Lessons Learned Building Andúril

1. GitHub API Complexity

Working with GitHub's API taught me about enterprise integration challenges:

  • Rate limiting requires sophisticated request queuing
  • Webhook reliability needs robust error handling and retries
  • Data consistency between GitHub and local state is tricky

2. Developer Tool UX is Different

Building for developers requires different UX principles:

  • Keyboard shortcuts are absolutely critical
  • Command palette feels more natural than traditional menus
  • Real-time feedback prevents context switching anxiety
  • Customization is expected, not optional

3. Productivity Tool Architecture

Effective productivity tools need specific architectural patterns:

// Event-driven architecture for GitHub updates
class EventBus {
  private handlers = new Map<string, EventHandler[]>();
  
  on<T>(event: string, handler: (data: T) => void) {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, []);
    }
    this.handlers.get(event)?.push(handler);
  }
  
  emit<T>(event: string, data: T) {
    const handlers = this.handlers.get(event) || [];
    handlers.forEach(handler => handler(data));
  }
}

// Usage for GitHub events
eventBus.on('github:issue:opened', (issue) => {
  notificationManager.notify(`New issue: ${issue.title}`);
  analyticsTracker.track('issue_created', issue);
});

eventBus.on('github:pr:merged', (pr) => {
  celebrationManager.celebrate(`🎉 PR merged: ${pr.title}`);
  velocityTracker.recordMerge(pr);
});

What I Discovered About Developer Tools

Essential Components

  1. GitHub Integration Layer - Robust API client with rate limiting
  2. Real-time Synchronization - WebSocket-based updates
  3. Command Interface - Keyboard-driven interactions
  4. Analytics Engine - Insights into team productivity

Performance Considerations

  • Optimistic updates for better perceived performance
  • Background synchronization to keep data fresh
  • Intelligent caching to reduce API calls
  • Lazy loading for large repositories

Technical Stack Summary

Frontend:

  • Next.js 14 with TypeScript
  • React 18 with modern hooks
  • Tailwind CSS for styling
  • Radix UI for accessible components

Backend & Integration:

  • GitHub API via Octokit
  • PostgreSQL with Prisma ORM
  • WebSocket for real-time updates
  • GitHub App for enhanced permissions

Developer Experience:

  • Command palette with fuzzy search
  • Comprehensive keyboard shortcuts
  • Dark mode optimized for coding
  • Responsive design for all screen sizes

Building in Public: What I Learned

Creating Andúril taught me valuable lessons about developer tooling:

About GitHub Integration

  • The complexity of maintaining consistent state with external APIs
  • How webhook reliability impacts user experience
  • The importance of graceful degradation when APIs are unavailable

About Developer Productivity

  • Small improvements in daily workflows compound significantly
  • Context switching is the biggest productivity killer
  • Automation should enhance human decision-making, not replace it

About Tool Adoption

  • Developers prefer tools that integrate with existing workflows
  • Customization and keyboard shortcuts drive power user adoption
  • Performance and reliability matter more than features

The Numbers After 8 Months

While Andúril is primarily an exploration project:

  • 8,000+ lines of TypeScript code
  • 100+ React components with full accessibility
  • 30+ GitHub API endpoints integrated
  • 50+ keyboard shortcuts for power users

Explore Andúril

Andúril is live at anduril.onrender.com and available on GitHub. It demonstrates:

  • Deep GitHub integration patterns
  • Real-time collaborative features
  • Developer-focused UX design
  • Scalable productivity tool architecture

The most valuable insight? Great developer tools feel like natural extensions of your existing workflow.


This is part of my "building in public" series where I explore how complex tools work by building them from scratch. Andúril taught me more about developer productivity and GitHub integration than years of using existing tools.

🔔 Subscribe for more
Want to know when I post a new article? Follow me on Twitter or subscribe to the RSS Feed