Skip to content
Draft
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
61 changes: 61 additions & 0 deletions tools/github-manage/cmd/gm/jarvis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"fleetdm/gm/pkg/ghapi"
"fleetdm/gm/pkg/jarvis"

"github.com/spf13/cobra"
)

var jarvisCmd = &cobra.Command{
Use: "jarvis",
Short: "Personal work dashboard — your GitHub work, sorted by leverage",
Long: `jarvis is an interactive TUI that aggregates your open GitHub work into
leverage-ordered buckets: what's blocking others, what you can merge right now,
what needs your hands, your review queue, and what's gone cold.

Navigate with ↑/↓, open an item with enter, refresh everything with r or just
the highlighted item with R. Press f for the
Focus view — an issue-centric card list of the work you've pinned, each showing
its project Status, linked PR, Claude session, and the next step to take.

Actions drive the development lifecycle:
- w start work: branch off main in a local clone, launch a Claude session,
and set the issue's project Status to In progress
- v mark the issue In review · m merge (advances the issue to Awaiting QA)
- M merge + start a Claude cherry-pick session for the merged PR
- a mark Awaiting QA · p pin/unpin to Focus
- b open the selected issue's most recently updated project board

With primary_projects set in ~/.config/gm/jarvis/config.json (a list of project
numbers, gm aliases, or names, e.g. ["g-apple-at-work", "g-auto-patching"]), the
top "PROJECT VIEW" section groups by project: each project is a selectable row
(press b/enter to open its board) showing the issues assigned to you in any
status except Done / Ready for release, plus a count of unassigned issues in the
Ready column. Projects always appear even with no issues, so you can open one to
pick up new work. Managers of multiple teams can list several.

Sources:
- pull requests you authored (mergeability, CI, review state)
- pull requests awaiting your review
- issues assigned to you, with their project board Status

Fetches are cached at ~/.config/gm/jarvis/cache.json; jarvis opens instantly
from a cache younger than 4h (press r to refresh, or pass --no-cache to force a
live pull on startup).

Local clones for "start work" are discovered under the directories in
~/.config/gm/jarvis/config.json (clone_base_dirs; defaults to ~/projects).`,
RunE: func(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
limit, _ := cmd.Flags().GetInt("limit")
noCache, _ := cmd.Flags().GetBool("no-cache")
return jarvis.Run(repo, limit, noCache)
},
}

func init() {
jarvisCmd.Flags().StringP("repo", "R", ghapi.DefaultRepo, "Repository to scan (owner/name)")
jarvisCmd.Flags().IntP("limit", "l", 100, "Max items to fetch per source")
jarvisCmd.Flags().Bool("no-cache", false, "Ignore the cached fetch and pull live on startup (data is cached for 4h by default; press r to refresh anytime)")
}
1 change: 1 addition & 0 deletions tools/github-manage/cmd/gm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func main() {
rootCmd.AddCommand(releasesCmd)
rootCmd.AddCommand(preSprintCmd)
rootCmd.AddCommand(bugsCmd)
rootCmd.AddCommand(jarvisCmd)

// Test command to test SetCurrentSprint functionality
rootCmd.AddCommand(&cobra.Command{
Expand Down
1 change: 1 addition & 0 deletions tools/github-manage/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (

require (
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions tools/github-manage/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
Expand Down
53 changes: 53 additions & 0 deletions tools/github-manage/pkg/ghapi/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package ghapi
import (
"bytes"
"os/exec"
"strings"
"time"

"fleetdm/gm/pkg/logger"
)
Expand All @@ -21,3 +23,54 @@ func RunCommandAndReturnOutput(command string) ([]byte, error) {
}
return out.Bytes(), nil
}

// RunGH runs the gh CLI with explicit arguments (no shell), which is safe for
// user-supplied input like comment bodies. Combined stdout+stderr is returned.
func RunGH(args ...string) ([]byte, error) {
logger.Debugf("Running gh %s", strings.Join(args, " "))
cmd := exec.Command("gh", args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
logger.Errorf("gh error: %s", out.String())
return out.Bytes(), err
}
return out.Bytes(), nil
}

// isTransient reports whether command output looks like a retryable GitHub error
// (transient 5xx / gateway / timeout), as opposed to a permanent failure.
func isTransient(out []byte) bool {
s := string(out)
for _, marker := range []string{"HTTP 502", "HTTP 503", "HTTP 504", "Bad Gateway", "Service Unavailable", "Gateway Timeout", "timeout", "EOF"} {
if strings.Contains(s, marker) {
return true
}
}
return false
}

// RunCommandWithRetry runs a command, retrying with exponential backoff when the
// failure looks transient (GitHub 5xx/gateway/timeout). Permanent errors return
// immediately. The output of the last attempt is returned alongside the error.
func RunCommandWithRetry(command string, attempts int) ([]byte, error) {
if attempts < 1 {
attempts = 1
}
var out []byte
var err error
for i := 0; i < attempts; i++ {
out, err = RunCommandAndReturnOutput(command)
if err == nil {
return out, nil
}
if i == attempts-1 || !isTransient(out) {
return out, err
}
backoff := time.Duration(1<<i) * time.Second
logger.Debugf("transient error, retrying in %s (attempt %d/%d)", backoff, i+1, attempts)
time.Sleep(backoff)
}
return out, err
}
67 changes: 32 additions & 35 deletions tools/github-manage/pkg/ghapi/milestone.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,28 +160,43 @@ func GetProjectsForIssues(issueNumbers []int, progress func(current, total, issu
// GetIssueProjectStatuses returns a map of projectID -> Status value for an issue across given projects.
// If the issue is not present in a project or the Status is unset, the value will be an empty string.
type ProjectStatus struct {
Present bool // true if the issue is in this project
Status string // "" when unset
Present bool // true if the issue is in this project
Status string // "" when unset
UpdatedAt string // the project's last-updated timestamp (RFC3339), "" if unknown
Title string // the project's title
}

func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectStatus, error) {
// Single GraphQL query to fetch all project items and their Status field for this issue
owner, repo, err := getRepoOwnerAndName()
found, err := GetAllIssueProjectStatuses(issueNumber)
if err != nil {
// If repo cannot be determined, return absent for all
res := make(map[int]ProjectStatus, len(projects))
for _, pid := range projects {
found = map[int]ProjectStatus{}
}
// Compose response for requested projects, marking absences with Present=false.
res := make(map[int]ProjectStatus, len(projects))
for _, pid := range projects {
if ps, ok := found[pid]; ok {
res[pid] = ps
} else {
res[pid] = ProjectStatus{Present: false, Status: ""}
}
return res, nil
}
return res, nil
}

// GetAllIssueProjectStatuses returns the Status value for every project the issue
// belongs to, keyed by project number. One GraphQL call; unset Status is "".
func GetAllIssueProjectStatuses(issueNumber int) (map[int]ProjectStatus, error) {
owner, repo, err := getRepoOwnerAndName()
if err != nil {
return nil, err
}

query := `query($owner:String!,$repo:String!,$number:Int!){
repository(owner:$owner,name:$repo){
issue(number:$number){
projectItems(first:100){
nodes{
project{ number title }
project{ number title updatedAt }
fieldValues(first:50){
nodes{
__typename
Expand All @@ -199,12 +214,7 @@ func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectSt
cmd := fmt.Sprintf("gh api graphql -f query='%s' -f owner='%s' -f repo='%s' -F number=%d", query, owner, repo, issueNumber)
out, err := runCommandWithRetry(cmd, 5, 2*time.Second)
if err != nil {
// On error, default to absent for all requested projects
res := make(map[int]ProjectStatus, len(projects))
for _, pid := range projects {
res[pid] = ProjectStatus{Present: false, Status: ""}
}
return res, nil
return nil, err
}
var resp struct {
Data struct {
Expand All @@ -213,8 +223,9 @@ func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectSt
ProjectItems struct {
Nodes []struct {
Project struct {
Number int `json:"number"`
Title string `json:"title"`
Number int `json:"number"`
Title string `json:"title"`
UpdatedAt string `json:"updatedAt"`
} `json:"project"`
FieldValues struct {
Nodes []struct {
Expand All @@ -232,14 +243,10 @@ func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectSt
} `json:"data"`
}
if err := json.Unmarshal(out, &resp); err != nil {
res := make(map[int]ProjectStatus, len(projects))
for _, pid := range projects {
res[pid] = ProjectStatus{Present: false, Status: ""}
}
return res, nil
return nil, err
}

// Build a map of project number -> status value
// Build a map of project number -> status value.
found := make(map[int]ProjectStatus)
for _, node := range resp.Data.Repository.Issue.ProjectItems.Nodes {
pid := node.Project.Number
Expand All @@ -250,19 +257,9 @@ func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectSt
break
}
}
found[pid] = ProjectStatus{Present: true, Status: statusVal}
}

// Compose response for requested projects, marking absences with Present=false
res := make(map[int]ProjectStatus, len(projects))
for _, pid := range projects {
if ps, ok := found[pid]; ok {
res[pid] = ps
} else {
res[pid] = ProjectStatus{Present: false, Status: ""}
}
found[pid] = ProjectStatus{Present: true, Status: statusVal, UpdatedAt: node.Project.UpdatedAt, Title: node.Project.Title}
}
return res, nil
return found, nil
}

// runCommandWithRetry executes a shell command capturing combined output and retries with
Expand Down
1 change: 1 addition & 0 deletions tools/github-manage/pkg/ghapi/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Issue struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url,omitempty"`
Body string `json:"body"`
Author Author `json:"author"`
Assignees []Author `json:"assignees"`
Expand Down
75 changes: 75 additions & 0 deletions tools/github-manage/pkg/ghapi/notifications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package ghapi

import (
"encoding/json"
"fmt"
"strconv"
"strings"
)

// Notification is a GitHub notification. The Reason field encodes why it's in
// your inbox (mention, review_requested, assign, author, comment, team_mention,
// ci_activity, ...) — the canonical "waiting on me" signal.
type Notification struct {
Reason string `json:"reason"`
Unread bool `json:"unread"`
UpdatedAt string `json:"updated_at"`
Subject struct {
Title string `json:"title"`
URL string `json:"url"`
Type string `json:"type"` // Issue, PullRequest, ...
} `json:"subject"`
Repository struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
} `json:"repository"`
}

// IsPR reports whether the notification subject is a pull request.
func (n Notification) IsPR() bool { return n.Subject.Type == "PullRequest" }

// Number extracts the issue/PR number from the subject API URL, or 0 if absent
// (e.g. release or discussion notifications).
func (n Notification) Number() int {
parts := strings.Split(strings.TrimRight(n.Subject.URL, "/"), "/")
if len(parts) == 0 {
return 0
}
num, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
return 0
}
return num
}

// HTMLURL builds the browser URL for the subject.
func (n Notification) HTMLURL() string {
num := n.Number()
if num == 0 || n.Repository.HTMLURL == "" {
return ""
}
seg := "issues"
if n.IsPR() {
seg = "pull"
}
return fmt.Sprintf("%s/%s/%d", n.Repository.HTMLURL, seg, num)
}

// GetNotifications returns the authenticated user's unread notifications for the
// repo. Requires the gh token to have the notifications scope; callers should
// treat a failure as non-fatal.
func GetNotifications(repo string) ([]Notification, error) {
if repo == "" {
repo = DefaultRepo
}
cmd := fmt.Sprintf("gh api /repos/%s/notifications --paginate", repo)
out, err := RunCommandWithRetry(cmd, 3)
if err != nil {
return nil, err
}
var ns []Notification
if err := json.Unmarshal(out, &ns); err != nil {
return nil, err
}
return ns, nil
}
27 changes: 27 additions & 0 deletions tools/github-manage/pkg/ghapi/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,33 @@ func GetProjectItems(projectID, limit int) ([]ProjectItem, error) { // backward
return items, err
}

// OrgProject is a lightweight org-level project listing entry.
type OrgProject struct {
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Closed bool `json:"closed"`
}

// ListOrgProjects lists an org's projects (number, title, URL) so callers can
// resolve a project name to its number/URL.
func ListOrgProjects(owner string) ([]OrgProject, error) {
if owner == "" {
owner = "fleetdm"
}
out, err := RunCommandWithRetry(fmt.Sprintf("gh project list --owner %s --format json --limit 200", owner), 3)
if err != nil {
return nil, err
}
var resp struct {
Projects []OrgProject `json:"projects"`
}
if err := json.Unmarshal(out, &resp); err != nil {
return nil, err
}
return resp.Projects, nil
}

// GetCurrentSprintItems returns only the items in the current sprint for a project.
// It first fetches all items (up to limit) and then filters to those that have a non-nil
// Sprint field whose Title matches the active iteration. The active iteration is determined
Expand Down
Loading
Loading