~rockorager/shtc

d78a6097081793c4c8249cb437c085a8e11efffe — Tim Culverhouse 11 months ago 0dae4a0 main
git: show basic log info

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
M client/client.go => client/client.go +48 -101
@@ 15,6 15,7 @@ import (
	"git.sr.ht/~rockorager/shtc"
	"git.sr.ht/~rockorager/shtc/config"
	"git.sr.ht/~rockorager/shtc/git"
	"git.sr.ht/~rockorager/shtc/lists"
	"git.sr.ht/~rockorager/shtc/log"
	"git.sr.ht/~rockorager/vaxis"
	"github.com/kyoh86/xdg"


@@ 174,110 175,56 @@ func (w *Worker) HandleCmd(cmd vaxis.Msg) {
			},
			Model: cmd.From,
		})
	case shtc.FetchMailingLists:
		var (
			cursor *lists.Cursor
			mlists []lists.MailingList
		)
		for {
			resp, err := lists.MailingLists(w.lists, cmd.Context, cursor)
			if err != nil {
				log.Error(err)
				return
			}
			if resp.Lists == nil {
				break
			}
			mlists = append(mlists, resp.Lists.Results...)
			if resp.Lists.Cursor == nil {
				break
			}
			cursor = resp.Lists.Cursor
		}
		vaxis.PostMsg(vaxis.SendMsg{
			Msg:   mlists,
			Model: cmd.From,
		})
		vaxis.PostMsg(mlists)
	case shtc.FetchGitLog:
		user, err := git.Log(w.git, cmd.Context, cmd.Username, cmd.Name)
		if err != nil {
			log.Error(err)
			return
		}
		if user == nil || user.Repository == nil || user.Repository.Log == nil {
			return
		}

		msgs := make([]string, 0, len(user.Repository.Log.Results))
		for _, c := range user.Repository.Log.Results {
			msgs = append(msgs, c.Message)
		}
		vaxis.PostMsg(vaxis.SendMsg{
			Msg: shtc.GitLog{
				Name: cmd.Name,
				Log:  msgs,
			},
			Model: cmd.From,
		})

	}
}

// case hutui.FetchMyRepoList:
// 	var (
// 		cursor *git.Cursor
// 		repos  []git.Repository
// 	)
// 	for {
// 		resp, err := git.Repositories(w.git, msg.Context, cursor)
// 		if err != nil {
// 			log.Error(err)
// 			rtk.PostMsg(rtk.ErrorMsg(err))
// 			return
// 		}
// 		repos = append(repos, resp.Results...)
// 		if resp.Cursor == nil {
// 			break
// 		}
// 		cursor = resp.Cursor
// 	}
// 	rtk.PostMsg(repos)
// }
// case hutui.FetchUserRepoList:
// 	var (
// 		cursor *git.Cursor
// 		repos  []git.Repository
// 	)
// 	for {
// 		user, err := git.RepositoriesByUser(w.git, cmd.Context, cmd.Username, cursor)
// 		if err != nil {
// 			log.Error(err)
// 			rtk.PostMsg(rtk.ErrorMsg(err))
// 			return
// 		}
// 		if user.Repositories == nil {
// 			log.Errorf("unknown error fetching repositories for user %s", cmd.Username)
// 		}
// 		repos = append(repos, user.Repositories.Results...)
// 		if user.Repositories.Cursor == nil {
// 			break
// 		}
// 		cursor = user.Repositories.Cursor
// 	}
// 	rtk.PostMsg(repos)
// case hutui.OpenRepo:
// 	// see if the destination already exists, in which case we pull
// 	dest := path.Join(w.cache, cmd.Name)
// 	_, err := os.Stat(dest)
// 	switch {
// 	case os.IsNotExist(err):
// 		rtk.PostMsg(hutui.Status("cloning " + cmd.Name))
// 		// We've never seen this one before, clone
// 		err = os.MkdirAll(dest, 0o755)
// 		url := serviceURI("git") + "/" + cmd.Name
// 		gitCmdStr := fmt.Sprintf("git clone --depth=1 %s %s", url, dest)
// 		gitCmd := exec.Command("sh", "-c", gitCmdStr)
// 		err := gitCmd.Run()
// 		if err != nil {
// 			rtk.PostMsg(rtk.ErrorMsg(err))
// 			log.Errorf("couldn't clone %s: %v", dest, err)
// 			err := os.Remove(dest)
// 			if err != nil {
// 				log.Errorf("couldn't remove directory: %s", dest)
// 			}
// 			return
// 		}
// 		log.Infof("cloned %s to %s", url, dest)
// 	case err != nil:
// 		log.Error(err)
// 		rtk.PostMsg(rtk.ErrorMsg(err))
// 		return
// 	default:
// 		// Pull
// 		rtk.PostMsg(hutui.Status("pulling " + cmd.Name))
// 		gitCmdStr := "git pull"
// 		gitCmd := exec.Command("sh", "-c", gitCmdStr)
// 		gitCmd.Dir = dest
// 		err = gitCmd.Run()
// 		if err != nil {
// 			log.Error(err)
// 			rtk.PostMsg(rtk.ErrorMsg(err))
// 			return
// 		}
// 		log.Infof("pulled %s", dest)
// 	}
//
// 	// Find a readme
// 	matches, err := filepath.Glob(path.Join(dest, "README*"))
// 	if err != nil {
// 		// Our pattern is good, not sure how we got here
// 		log.Errorf("couldn't glob: %v", err)
// 	}
// 	openPath := dest
// 	for _, m := range matches {
// 		// Take the first of any globbed matches
// 		openPath = m
// 		break
// 	}
// 	rtk.PostMsg(hutui.Repository{
// 		Name:   cmd.Name,
// 		Root:   dest,
// 		Readme: openPath,
// 	})
// case hutui.FetchTrackerList:
// 	var (
// 		cursor   *todo.Cursor

M commands.go => commands.go +12 -0
@@ 22,3 22,15 @@ type OpenRepo struct {
	From    vaxis.Model
	Name    string
}

type FetchMailingLists struct {
	Context context.Context
	From    vaxis.Model
}

type FetchGitLog struct {
	Context  context.Context
	From     vaxis.Model
	Name     string
	Username string
}

M generate.go => generate.go +1 -0
@@ 1,3 1,4 @@
package shtc

//go:generate go run git.sr.ht/~emersion/gqlclient/cmd/gqlclientgen -s git/schema.graphql -q git/operations.graphql -o git/git.go
//go:generate go run git.sr.ht/~emersion/gqlclient/cmd/gqlclientgen -s lists/schema.graphql -q lists/operations.graphql -o lists/lists.go

M git/git.go => git/git.go +13 -2
@@ 592,7 592,7 @@ type WebhookSubscriptionCursor struct {
}

func RepositoriesByUser(client *gqlclient.Client, ctx context.Context, username string, cursor *Cursor) (user *User, err error) {
	op := gqlclient.NewOperation("query repositoriesByUser ($username: String!, $cursor: Cursor) {\n\tuser(username: $username) {\n\t\trepositories(cursor: $cursor) {\n\t\t\t... repos\n\t\t}\n\t}\n}\nfragment repos on RepositoryCursor {\n\tresults {\n\t\tid\n\t\tname\n\t\tdescription\n\t\tvisibility\n\t\tupdated\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t}\n\tcursor\n}\n")
	op := gqlclient.NewOperation("query repositoriesByUser ($username: String!, $cursor: Cursor) {\n\tuser(username: $username) {\n\t\trepositories(cursor: $cursor, filter: {count:100}) {\n\t\t\t... repos\n\t\t}\n\t}\n}\nfragment repos on RepositoryCursor {\n\tresults {\n\t\tid\n\t\tname\n\t\tdescription\n\t\tvisibility\n\t\tupdated\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t}\n\tcursor\n}\n")
	op.Var("username", username)
	op.Var("cursor", cursor)
	var respData struct {


@@ 603,7 603,7 @@ func RepositoriesByUser(client *gqlclient.Client, ctx context.Context, username 
}

func Repositories(client *gqlclient.Client, ctx context.Context, cursor *Cursor) (repositories *RepositoryCursor, err error) {
	op := gqlclient.NewOperation("query repositories ($cursor: Cursor) {\n\trepositories(cursor: $cursor) {\n\t\t... repos\n\t}\n}\nfragment repos on RepositoryCursor {\n\tresults {\n\t\tid\n\t\tname\n\t\tdescription\n\t\tvisibility\n\t\tupdated\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t}\n\tcursor\n}\n")
	op := gqlclient.NewOperation("query repositories ($cursor: Cursor) {\n\trepositories(cursor: $cursor, filter: {count:100}) {\n\t\t... repos\n\t}\n}\nfragment repos on RepositoryCursor {\n\tresults {\n\t\tid\n\t\tname\n\t\tdescription\n\t\tvisibility\n\t\tupdated\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t}\n\tcursor\n}\n")
	op.Var("cursor", cursor)
	var respData struct {
		Repositories *RepositoryCursor


@@ 612,6 612,17 @@ func Repositories(client *gqlclient.Client, ctx context.Context, cursor *Cursor)
	return respData.Repositories, err
}

func Log(client *gqlclient.Client, ctx context.Context, username string, name string) (user *User, err error) {
	op := gqlclient.NewOperation("query log ($username: String!, $name: String!) {\n\tuser(username: $username) {\n\t\trepository(name: $name) {\n\t\t\tlog {\n\t\t\t\tresults {\n\t\t\t\t\tmessage\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n")
	op.Var("username", username)
	op.Var("name", name)
	var respData struct {
		User *User
	}
	err = client.Execute(ctx, op, &respData)
	return respData.User, err
}

func CreateRepository(client *gqlclient.Client, ctx context.Context, name string, visibility Visibility, description string, cloneUrl *string) (createRepository *Repository, err error) {
	op := gqlclient.NewOperation("mutation createRepository ($name: String!, $visibility: Visibility!, $description: String!, $cloneUrl: String) {\n\tcreateRepository(name: $name, visibility: $visibility, description: $description, cloneUrl: $cloneUrl) {\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t\tname\n\t}\n}\n")
	op.Var("name", name)

M git/model.go => git/model.go +52 -2
@@ 18,16 18,20 @@ import (
)

type Model struct {
	ctx       context.Context
	activeLog *shtc.GitLog
	tab       *widgets.Tab
	filter    *textinput.Model
	editor    *term.Model
	scrollbar *scrollbar.Model
	cancel    context.CancelFunc
	filtered  []Repository
	logs      []shtc.GitLog
	repos     []Repository
	selected  int
	top       int
	n         int
	filtering bool
	scrollbar *scrollbar.Model
}

func NewModel() *Model {


@@ 40,6 44,7 @@ func NewModel() *Model {
		},
		scrollbar: &scrollbar.Model{},
	}
	m.ctx, m.cancel = context.WithCancel(context.Background())
	return m
}



@@ 62,6 67,7 @@ func (m *Model) Update(msg vaxis.Msg) {
		}
		m.repos = append(m.repos, msg...)
		m.updateFiltered()
		m.requestLog()
	case vaxis.Key:
		if m.editor != nil {
			m.editor.Update(msg)


@@ 73,6 79,7 @@ func (m *Model) Update(msg vaxis.Msg) {
				// Catching escape here to handle below
			case "Enter":
				m.filtering = false
				m.requestLog()
				return
			default:
				m.top = 0


@@ 110,21 117,27 @@ func (m *Model) Update(msg vaxis.Msg) {
			})
		case "j", "Down":
			m.Next()
			m.requestLog()
		case "k", "Up":
			m.Prev()
			m.requestLog()
		case "g":
			m.selected = 0
			m.top = 0
			m.requestLog()
		case "G":
			m.selected = len(m.filtered) - 1
			m.requestLog()
		case "Ctrl+d":
			for i := 0; i < (m.n / 2); i += 1 {
				m.Next()
			}
			m.requestLog()
		case "Ctrl+u":
			for i := 0; i < (m.n / 2); i += 1 {
				m.Prev()
			}
			m.requestLog()
		case "/":
			m.filtering = true
		}


@@ 158,6 171,9 @@ func (m *Model) Update(msg vaxis.Msg) {
				From:     m,
			})
		}
	case shtc.GitLog:
		m.logs = append(m.logs, msg)
		m.requestLog()
	}
}



@@ 175,7 191,7 @@ func (m *Model) Draw(win vaxis.Window) {
		Text:       " | ",
		Foreground: vaxis.IndexColor(7),
	}
	listWin := vaxis.NewWindow(&win, 0, 0, 0, h-1)
	listWin := vaxis.NewWindow(&win, 0, 0, w/2, h-1)
	// h-1 because we leave the bottom row empty
	for i := 0; i < (h - 1); i += 1 {
		if len(m.filtered) == 0 {


@@ 290,6 306,16 @@ func (m *Model) Draw(win vaxis.Window) {
			vaxis.HideCursor()
		}
	}
	logWin := vaxis.NewWindow(&win, w/2, 0, 0, 0)
	if m.activeLog != nil {
		segs := []vaxis.Segment{}
		for _, msg := range m.activeLog.Log {
			segs = append(segs, vaxis.Segment{
				Text: msg + "\n\n",
			})
		}
		vaxis.PrintSegments(logWin, segs...)
	}
	scrollWin := vaxis.NewWindow(&win, w-1, 0, 1, 0)
	vaxis.Clear(scrollWin)
	m.scrollbar.TotalHeight = len(m.filtered)


@@ 301,6 327,30 @@ func (m *Model) Draw(win vaxis.Window) {
	}
}

func (m *Model) requestLog() {
	if len(m.filtered) == 0 {
		return
	}
	repo := m.filtered[m.selected]
	for _, l := range m.logs {
		if l.Name == repo.Name {
			m.activeLog = &l
			return
		}
	}
	if m.cancel != nil {
		m.cancel()
	}
	ctx, cancel := context.WithCancel(context.Background())
	m.cancel = cancel
	vaxis.PostCmd(shtc.FetchGitLog{
		Context:  ctx,
		Username: strings.TrimPrefix(repo.Owner.CanonicalName, "~"),
		Name:     repo.Name,
		From:     m,
	})
}

func (m *Model) Next() {
	if m.selected == len(m.filtered)-1 {
		return

M git/operations.graphql => git/operations.graphql +14 -2
@@ 1,17 1,29 @@
query repositoriesByUser($username: String!, $cursor: Cursor) {
  user(username: $username) {
    repositories(cursor: $cursor) {
    repositories(cursor: $cursor, filter: { count: 100 }) {
      ...repos
    }
  }
}

query repositories($cursor: Cursor) {
  repositories(cursor: $cursor) {
  repositories(cursor: $cursor, filter: { count: 100 }) {
    ...repos
  }
}

query log($username: String!, $name: String!) {
	user(username: $username) {
		repository(name: $name) {
			log() {
			results {
					message,
				}
		}
		}
	}
}

fragment repos on RepositoryCursor {
  results {
    id

A lists/lists.go => lists/lists.go +752 -0
@@ 0,0 1,752 @@
// Code generated by gqlclientgen - DO NOT EDIT.

package lists

import (
	"context"
	"encoding/json"
	"fmt"
	gqlclient "git.sr.ht/~emersion/gqlclient"
)

type ACL struct {
	// Permission to browse or subscribe to emails
	Browse bool `json:"browse"`
	// Permission to reply to existing threads
	Reply bool `json:"reply"`
	// Permission to start new threads
	Post bool `json:"post"`
	// Permission to moderate the list
	Moderate bool `json:"moderate"`

	// Underlying value of the GraphQL interface
	Value ACLValue `json:"-"`
}

func (base *ACL) UnmarshalJSON(b []byte) error {
	type Raw ACL
	var data struct {
		*Raw
		TypeName string `json:"__typename"`
	}
	data.Raw = (*Raw)(base)
	err := json.Unmarshal(b, &data)
	if err != nil {
		return err
	}
	switch data.TypeName {
	case "MailingListACL":
		base.Value = new(MailingListACL)
	case "GeneralACL":
		base.Value = new(GeneralACL)
	case "":
		return nil
	default:
		return fmt.Errorf("gqlclient: interface ACL: unknown __typename %q", data.TypeName)
	}
	return json.Unmarshal(b, base.Value)
}

// ACLValue is one of: MailingListACL | GeneralACL
type ACLValue interface {
	isACL()
}

type ACLInput struct {
	Browse   bool `json:"browse"`
	Reply    bool `json:"reply"`
	Post     bool `json:"post"`
	Moderate bool `json:"moderate"`
}

type AccessKind string

const (
	AccessKindRo AccessKind = "RO"
	AccessKindRw AccessKind = "RW"
)

type AccessScope string

const (
	AccessScopeAcls          AccessScope = "ACLS"
	AccessScopeEmails        AccessScope = "EMAILS"
	AccessScopeLists         AccessScope = "LISTS"
	AccessScopePatches       AccessScope = "PATCHES"
	AccessScopeProfile       AccessScope = "PROFILE"
	AccessScopeSubscriptions AccessScope = "SUBSCRIPTIONS"
)

type ActivitySubscription struct {
	Id      int32          `json:"id"`
	Created gqlclient.Time `json:"created"`

	// Underlying value of the GraphQL interface
	Value ActivitySubscriptionValue `json:"-"`
}

func (base *ActivitySubscription) UnmarshalJSON(b []byte) error {
	type Raw ActivitySubscription
	var data struct {
		*Raw
		TypeName string `json:"__typename"`
	}
	data.Raw = (*Raw)(base)
	err := json.Unmarshal(b, &data)
	if err != nil {
		return err
	}
	switch data.TypeName {
	case "MailingListSubscription":
		base.Value = new(MailingListSubscription)
	case "":
		return nil
	default:
		return fmt.Errorf("gqlclient: interface ActivitySubscription: unknown __typename %q", data.TypeName)
	}
	return json.Unmarshal(b, base.Value)
}

// ActivitySubscriptionValue is one of: MailingListSubscription
type ActivitySubscriptionValue interface {
	isActivitySubscription()
}

// A cursor for enumerating subscriptions
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type ActivitySubscriptionCursor struct {
	Results []ActivitySubscription `json:"results"`
	Cursor  *Cursor                `json:"cursor,omitempty"`
}

// A byte range.
type ByteRange struct {
	// Inclusive start byte offset.
	Start int32 `json:"start"`
	// Exclusive end byte offset.
	End int32 `json:"end"`
}

// Opaque string
type Cursor string

type Email struct {
	Id int32 `json:"id"`
	// The entity which sent this email. Will be a User if it can be associated
	// with an account, or a Mailbox otherwise.
	Sender *Entity `json:"sender"`
	// Time we received this email (non-forgable).
	Received gqlclient.Time `json:"received"`
	// Time given by Date header (forgable).
	Date gqlclient.Time `json:"date,omitempty"`
	// The Subject header.
	Subject string `json:"subject"`
	// The Message-ID header, without angle brackets.
	MessageID string `json:"messageID"`
	// The In-Reply-To header, if present, without angle brackets.
	InReplyTo *string `json:"inReplyTo,omitempty"`
	// Provides the value (or values) of a specific header from this email. Note
	// that the returned value is coerced to UTF-8 and may be lossy under certain
	// circumstances.
	Header []string `json:"header"`
	// Retrieves the value of an address list header, such as To or Cc.
	AddressList []Mailbox `json:"addressList"`
	// The decoded text/plain message part of the email, i.e. email body.
	Body string `json:"body"`
	// A URL from which the full raw message envelope may be downloaded.
	Envelope URL          `json:"envelope"`
	Thread   *Thread      `json:"thread"`
	Parent   *Email       `json:"parent,omitempty"`
	Patch    *Patch       `json:"patch,omitempty"`
	Patchset *Patchset    `json:"patchset,omitempty"`
	List     *MailingList `json:"list"`
}

// A cursor for enumerating emails
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type EmailCursor struct {
	Results []Email `json:"results"`
	Cursor  *Cursor `json:"cursor,omitempty"`
}

type EmailEvent struct {
	Uuid  string         `json:"uuid"`
	Event WebhookEvent   `json:"event"`
	Date  gqlclient.Time `json:"date"`
	Email *Email         `json:"email"`
}

func (*EmailEvent) isWebhookPayload() {}

type Entity struct {
	CanonicalName string `json:"canonicalName"`

	// Underlying value of the GraphQL interface
	Value EntityValue `json:"-"`
}

func (base *Entity) UnmarshalJSON(b []byte) error {
	type Raw Entity
	var data struct {
		*Raw
		TypeName string `json:"__typename"`
	}
	data.Raw = (*Raw)(base)
	err := json.Unmarshal(b, &data)
	if err != nil {
		return err
	}
	switch data.TypeName {
	case "User":
		base.Value = new(User)
	case "Mailbox":
		base.Value = new(Mailbox)
	case "":
		return nil
	default:
		return fmt.Errorf("gqlclient: interface Entity: unknown __typename %q", data.TypeName)
	}
	return json.Unmarshal(b, base.Value)
}

// EntityValue is one of: User | Mailbox
type EntityValue interface {
	isEntity()
}

// An ACL entry that applies "generally", for example the rights which apply to
// all subscribers to a list.
type GeneralACL struct {
	Browse   bool `json:"browse"`
	Reply    bool `json:"reply"`
	Post     bool `json:"post"`
	Moderate bool `json:"moderate"`
}

func (*GeneralACL) isACL() {}

// A mailbox not associated with a registered user
type Mailbox struct {
	CanonicalName string `json:"canonicalName"`
	Name          string `json:"name"`
	Address       string `json:"address"`
}

func (*Mailbox) isEntity() {}

type MailingList struct {
	Id          int32          `json:"id"`
	Created     gqlclient.Time `json:"created"`
	Updated     gqlclient.Time `json:"updated"`
	Name        string         `json:"name"`
	Owner       *Entity        `json:"owner"`
	Description *string        `json:"description,omitempty"`
	Visibility  Visibility     `json:"visibility"`
	// List of globs for permitted or rejected mimetypes on this list
	// e.g. text/*
	PermitMime []string `json:"permitMime"`
	RejectMime []string `json:"rejectMime"`
	// List of threads on this list in order of most recently bumped
	Threads *ThreadCursor `json:"threads"`
	// List of emails received on this list in reverse chronological order
	Emails *EmailCursor `json:"emails"`
	// List of patches received on this list in order of most recently bumped
	Patches *PatchsetCursor `json:"patches"`
	// True if an import operation is underway for this list
	Importing bool `json:"importing"`
	// The access that applies to this user for this list
	Access *ACL `json:"access"`
	// The user's subscription for this list, if any
	Subscription *MailingListSubscription `json:"subscription,omitempty"`
	// URLs to application/mbox archives for this mailing list
	Archive    URL `json:"archive"`
	Last30days URL `json:"last30days"`
	// Access control list entries for this mailing list
	Acl        *MailingListACLCursor `json:"acl"`
	DefaultACL *GeneralACL           `json:"defaultACL"`
	// Returns a list of mailing list webhook subscriptions. For clients
	// authenticated with a personal access token, this returns all webhooks
	// configured by all GraphQL clients for your account. For clients
	// authenticated with an OAuth 2.0 access token, this returns only webhooks
	// registered for your client.
	Webhooks *WebhookSubscriptionCursor `json:"webhooks"`
	// Returns details of a mailing list webhook subscription by its ID.
	Webhook *WebhookSubscription `json:"webhook,omitempty"`
}

// These ACLs are configured for specific entities, and may be used to expand or
// constrain the rights of a participant.
type MailingListACL struct {
	Id       int32          `json:"id"`
	Created  gqlclient.Time `json:"created"`
	List     *MailingList   `json:"list"`
	Entity   *Entity        `json:"entity"`
	Browse   bool           `json:"browse"`
	Reply    bool           `json:"reply"`
	Post     bool           `json:"post"`
	Moderate bool           `json:"moderate"`
}

func (*MailingListACL) isACL() {}

// A cursor for enumerating ACL entries
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type MailingListACLCursor struct {
	Results []MailingListACL `json:"results"`
	Cursor  *Cursor          `json:"cursor,omitempty"`
}

// A cursor for enumerating mailing lists
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type MailingListCursor struct {
	Results []MailingList `json:"results"`
	Cursor  *Cursor       `json:"cursor,omitempty"`
}

type MailingListEvent struct {
	Uuid  string         `json:"uuid"`
	Event WebhookEvent   `json:"event"`
	Date  gqlclient.Time `json:"date"`
	List  *MailingList   `json:"list"`
}

func (*MailingListEvent) isWebhookPayload() {}

type MailingListInput struct {
	Description *string     `json:"description,omitempty"`
	Visibility  *Visibility `json:"visibility,omitempty"`
	// List of globs for permitted or rejected mimetypes on this list
	// e.g. text/*
	PermitMime []string `json:"permitMime,omitempty"`
	RejectMime []string `json:"rejectMime,omitempty"`
}

type MailingListSubscription struct {
	Id      int32          `json:"id"`
	Created gqlclient.Time `json:"created"`
	List    *MailingList   `json:"list"`
}

func (*MailingListSubscription) isActivitySubscription() {}

type MailingListWebhookInput struct {
	Url    string         `json:"url"`
	Events []WebhookEvent `json:"events"`
	Query  string         `json:"query"`
}

type MailingListWebhookSubscription struct {
	Id         int32                  `json:"id"`
	Events     []WebhookEvent         `json:"events"`
	Query      string                 `json:"query"`
	Url        string                 `json:"url"`
	Client     *OAuthClient           `json:"client,omitempty"`
	Deliveries *WebhookDeliveryCursor `json:"deliveries"`
	Sample     string                 `json:"sample"`
	List       *MailingList           `json:"list"`
}

func (*MailingListWebhookSubscription) isWebhookSubscription() {}

type OAuthClient struct {
	Uuid string `json:"uuid"`
}

// Information parsed from the subject line of a patch, such that the following:
//
//     [PATCH myproject v2 3/4] Add foo to bar
//
// Will produce:
//
//     index: 3
//     count: 4
//     version: 2
//     prefix: "myproject"
//     subject: "Add foo to bar"
type Patch struct {
	Index   *int32  `json:"index,omitempty"`
	Count   *int32  `json:"count,omitempty"`
	Version *int32  `json:"version,omitempty"`
	Prefix  *string `json:"prefix,omitempty"`
	Subject *string `json:"subject,omitempty"`
}

type Patchset struct {
	Id           int32          `json:"id"`
	Created      gqlclient.Time `json:"created"`
	Updated      gqlclient.Time `json:"updated"`
	Subject      string         `json:"subject"`
	Version      int32          `json:"version"`
	Prefix       *string        `json:"prefix,omitempty"`
	Status       PatchsetStatus `json:"status"`
	Submitter    *Entity        `json:"submitter"`
	CoverLetter  *Email         `json:"coverLetter,omitempty"`
	Thread       *Thread        `json:"thread"`
	SupersededBy *Patchset      `json:"supersededBy,omitempty"`
	List         *MailingList   `json:"list"`
	Patches      *EmailCursor   `json:"patches"`
	Tools        []PatchsetTool `json:"tools"`
	// URL to an application/mbox archive of only the patches in this thread
	Mbox URL `json:"mbox"`
}

// A cursor for enumerating patchsets
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type PatchsetCursor struct {
	Results []Patchset `json:"results"`
	Cursor  *Cursor    `json:"cursor,omitempty"`
}

type PatchsetEvent struct {
	Uuid     string         `json:"uuid"`
	Event    WebhookEvent   `json:"event"`
	Date     gqlclient.Time `json:"date"`
	Patchset *Patchset      `json:"patchset"`
}

func (*PatchsetEvent) isWebhookPayload() {}

type PatchsetStatus string

const (
	PatchsetStatusUnknown       PatchsetStatus = "UNKNOWN"
	PatchsetStatusProposed      PatchsetStatus = "PROPOSED"
	PatchsetStatusNeedsRevision PatchsetStatus = "NEEDS_REVISION"
	PatchsetStatusSuperseded    PatchsetStatus = "SUPERSEDED"
	PatchsetStatusApproved      PatchsetStatus = "APPROVED"
	PatchsetStatusRejected      PatchsetStatus = "REJECTED"
	PatchsetStatusApplied       PatchsetStatus = "APPLIED"
)

// Used to add some kind of indicator for a third-party process associated with
// a patchset, such as a CI service validating the change.
type PatchsetTool struct {
	Id       int32          `json:"id"`
	Created  gqlclient.Time `json:"created"`
	Updated  gqlclient.Time `json:"updated"`
	Icon     ToolIcon       `json:"icon"`
	Details  string         `json:"details"`
	Patchset *Patchset      `json:"patchset"`
}

type Thread struct {
	Created      gqlclient.Time `json:"created"`
	Updated      gqlclient.Time `json:"updated"`
	Subject      string         `json:"subject"`
	Replies      int32          `json:"replies"`
	Participants int32          `json:"participants"`
	Sender       *Entity        `json:"sender"`
	Root         *Email         `json:"root"`
	List         *MailingList   `json:"list"`
	// Replies to this thread, in chronological order
	Descendants *EmailCursor `json:"descendants"`
	// A mailto: URI for replying to the latest message in this thread
	Mailto string `json:"mailto"`
	// URL to an application/mbox archive of this thread
	Mbox URL `json:"mbox"`
	// Thread parsed as a tree.
	//
	// The returned list is never empty. The first item is guaranteed to be the root
	// message. The blocks are sorted in topological order.
	Blocks []ThreadBlock `json:"blocks"`
}

// A block of text in an email thread.
//
// Blocks are parts of a message's body that aren't quotes of the parent message.
// A block can be a reply to a parent block, in which case the parentStart and
// parentEnd fields indicate which part of the parent message is replied to. A
// block can have replies, each of which will be represented by a block in the
// children field.
type ThreadBlock struct {
	// Unique identifier for this block.
	Key string `json:"key"`
	// The block's plain-text content.
	Body string `json:"body"`
	// Index of the parent block (if any) in Thread.blocks.
	Parent *int32 `json:"parent,omitempty"`
	// Replies to this block.
	//
	// The list items are indexes into Thread.blocks.
	Children []int32 `json:"children"`
	// The email this block comes from.
	Source *Email `json:"source"`
	// The range of this block in the source email body.
	SourceRange *ByteRange `json:"sourceRange"`
	// If this block is a reply to a particular chunk of the parent block, this
	// field indicates the range of that chunk in the parent's email body.
	ParentRange *ByteRange `json:"parentRange,omitempty"`
}

// A cursor for enumerating threads
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type ThreadCursor struct {
	Results []Thread `json:"results"`
	Cursor  *Cursor  `json:"cursor,omitempty"`
}

type ToolIcon string

const (
	ToolIconPending   ToolIcon = "PENDING"
	ToolIconWaiting   ToolIcon = "WAITING"
	ToolIconSuccess   ToolIcon = "SUCCESS"
	ToolIconFailed    ToolIcon = "FAILED"
	ToolIconCancelled ToolIcon = "CANCELLED"
)

// URL from which some secondary data may be retrieved. You must provide the
// same Authentication header to this address as you did to the GraphQL resolver
// which provided it. The URL is not guaranteed to be consistent for an extended
// length of time; applications should submit a new GraphQL query each time they
// wish to access the data at the provided URL.
type URL string

// A registered user
type User struct {
	Id            int32              `json:"id"`
	Created       gqlclient.Time     `json:"created"`
	Updated       gqlclient.Time     `json:"updated"`
	CanonicalName string             `json:"canonicalName"`
	Username      string             `json:"username"`
	Email         string             `json:"email"`
	Url           *string            `json:"url,omitempty"`
	Location      *string            `json:"location,omitempty"`
	Bio           *string            `json:"bio,omitempty"`
	List          *MailingList       `json:"list,omitempty"`
	Lists         *MailingListCursor `json:"lists"`
	Emails        *EmailCursor       `json:"emails"`
	Threads       *ThreadCursor      `json:"threads"`
	Patches       *PatchsetCursor    `json:"patches"`
}

func (*User) isEntity() {}

type UserWebhookInput struct {
	Url    string         `json:"url"`
	Events []WebhookEvent `json:"events"`
	Query  string         `json:"query"`
}

type UserWebhookSubscription struct {
	Id         int32                  `json:"id"`
	Events     []WebhookEvent         `json:"events"`
	Query      string                 `json:"query"`
	Url        string                 `json:"url"`
	Client     *OAuthClient           `json:"client,omitempty"`
	Deliveries *WebhookDeliveryCursor `json:"deliveries"`
	Sample     string                 `json:"sample"`
}

func (*UserWebhookSubscription) isWebhookSubscription() {}

type Version struct {
	Major int32 `json:"major"`
	Minor int32 `json:"minor"`
	Patch int32 `json:"patch"`
	// If this API version is scheduled for deprecation, this is the date on which
	// it will stop working; or null if this API version is not scheduled for
	// deprecation.
	DeprecationDate gqlclient.Time `json:"deprecationDate,omitempty"`
}

type Visibility string

const (
	VisibilityPublic   Visibility = "PUBLIC"
	VisibilityUnlisted Visibility = "UNLISTED"
	VisibilityPrivate  Visibility = "PRIVATE"
)

type WebhookDelivery struct {
	Uuid         string               `json:"uuid"`
	Date         gqlclient.Time       `json:"date"`
	Event        WebhookEvent         `json:"event"`
	Subscription *WebhookSubscription `json:"subscription"`
	RequestBody  string               `json:"requestBody"`
	// These details are provided only after a response is received from the
	// remote server. If a response is sent whose Content-Type is not text/*, or
	// cannot be decoded as UTF-8, the response body will be null. It will be
	// truncated after 64 KiB.
	ResponseBody    *string `json:"responseBody,omitempty"`
	ResponseHeaders *string `json:"responseHeaders,omitempty"`
	ResponseStatus  *int32  `json:"responseStatus,omitempty"`
}

// A cursor for enumerating a list of webhook deliveries
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type WebhookDeliveryCursor struct {
	Results []WebhookDelivery `json:"results"`
	Cursor  *Cursor           `json:"cursor,omitempty"`
}

type WebhookEvent string

const (
	WebhookEventListCreated      WebhookEvent = "LIST_CREATED"
	WebhookEventListUpdated      WebhookEvent = "LIST_UPDATED"
	WebhookEventListDeleted      WebhookEvent = "LIST_DELETED"
	WebhookEventEmailReceived    WebhookEvent = "EMAIL_RECEIVED"
	WebhookEventPatchsetReceived WebhookEvent = "PATCHSET_RECEIVED"
)

type WebhookPayload struct {
	Uuid  string         `json:"uuid"`
	Event WebhookEvent   `json:"event"`
	Date  gqlclient.Time `json:"date"`

	// Underlying value of the GraphQL interface
	Value WebhookPayloadValue `json:"-"`
}

func (base *WebhookPayload) UnmarshalJSON(b []byte) error {
	type Raw WebhookPayload
	var data struct {
		*Raw
		TypeName string `json:"__typename"`
	}
	data.Raw = (*Raw)(base)
	err := json.Unmarshal(b, &data)
	if err != nil {
		return err
	}
	switch data.TypeName {
	case "MailingListEvent":
		base.Value = new(MailingListEvent)
	case "EmailEvent":
		base.Value = new(EmailEvent)
	case "PatchsetEvent":
		base.Value = new(PatchsetEvent)
	case "":
		return nil
	default:
		return fmt.Errorf("gqlclient: interface WebhookPayload: unknown __typename %q", data.TypeName)
	}
	return json.Unmarshal(b, base.Value)
}

// WebhookPayloadValue is one of: MailingListEvent | EmailEvent | PatchsetEvent
type WebhookPayloadValue interface {
	isWebhookPayload()
}

type WebhookSubscription struct {
	Id     int32          `json:"id"`
	Events []WebhookEvent `json:"events"`
	Query  string         `json:"query"`
	Url    string         `json:"url"`
	// If this webhook was registered by an authorized OAuth 2.0 client, this
	// field is non-null.
	Client *OAuthClient `json:"client,omitempty"`
	// All deliveries which have been sent to this webhook.
	Deliveries *WebhookDeliveryCursor `json:"deliveries"`
	// Returns a sample payload for this subscription, for testing purposes
	Sample string `json:"sample"`

	// Underlying value of the GraphQL interface
	Value WebhookSubscriptionValue `json:"-"`
}

func (base *WebhookSubscription) UnmarshalJSON(b []byte) error {
	type Raw WebhookSubscription
	var data struct {
		*Raw
		TypeName string `json:"__typename"`
	}
	data.Raw = (*Raw)(base)
	err := json.Unmarshal(b, &data)
	if err != nil {
		return err
	}
	switch data.TypeName {
	case "UserWebhookSubscription":
		base.Value = new(UserWebhookSubscription)
	case "MailingListWebhookSubscription":
		base.Value = new(MailingListWebhookSubscription)
	case "":
		return nil
	default:
		return fmt.Errorf("gqlclient: interface WebhookSubscription: unknown __typename %q", data.TypeName)
	}
	return json.Unmarshal(b, base.Value)
}

// WebhookSubscriptionValue is one of: UserWebhookSubscription | MailingListWebhookSubscription
type WebhookSubscriptionValue interface {
	isWebhookSubscription()
}

// A cursor for enumerating a list of webhook subscriptions
//
// If there are additional results available, the cursor object may be passed
// back into the same endpoint to retrieve another page. If the cursor is null,
// there are no remaining results to return.
type WebhookSubscriptionCursor struct {
	Results []WebhookSubscription `json:"results"`
	Cursor  *Cursor               `json:"cursor,omitempty"`
}

func MailingLists(client *gqlclient.Client, ctx context.Context, cursor *Cursor) (me *User, err error) {
	op := gqlclient.NewOperation("query mailingLists ($cursor: Cursor) {\n\tme {\n\t\tlists(cursor: $cursor) {\n\t\t\t... lists\n\t\t}\n\t}\n}\nfragment lists on MailingListCursor {\n\tresults {\n\t\tname\n\t\tdescription\n\t\tvisibility\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t}\n\tcursor\n}\n")
	op.Var("cursor", cursor)
	var respData struct {
		Me *User
	}
	err = client.Execute(ctx, op, &respData)
	return respData.Me, err
}

func MailingListsByUser(client *gqlclient.Client, ctx context.Context, username string, cursor *Cursor) (user *User, err error) {
	op := gqlclient.NewOperation("query mailingListsByUser ($username: String!, $cursor: Cursor) {\n\tuser(username: $username) {\n\t\tlists(cursor: $cursor) {\n\t\t\t... lists\n\t\t}\n\t}\n}\nfragment lists on MailingListCursor {\n\tresults {\n\t\tname\n\t\tdescription\n\t\tvisibility\n\t\towner {\n\t\t\tcanonicalName\n\t\t}\n\t}\n\tcursor\n}\n")
	op.Var("username", username)
	op.Var("cursor", cursor)
	var respData struct {
		User *User
	}
	err = client.Execute(ctx, op, &respData)
	return respData.User, err
}

func ListPatches(client *gqlclient.Client, ctx context.Context, username string, name string, cursor *Cursor) (user *User, err error) {
	op := gqlclient.NewOperation("query listPatches ($username: String!, $name: String!, $cursor: Cursor) {\n\tuser(username: $username) {\n\t\tlist(name: $name) {\n\t\t\tpatches(cursor: $cursor) {\n\t\t\t\tresults {\n\t\t\t\t\tid\n\t\t\t\t\tsubject\n\t\t\t\t\tstatus\n\t\t\t\t\tcreated\n\t\t\t\t\tversion\n\t\t\t\t\tprefix\n\t\t\t\t\tsubmitter {\n\t\t\t\t\t\tcanonicalName\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tcursor\n\t\t\t}\n\t\t}\n\t}\n}\n")
	op.Var("username", username)
	op.Var("name", name)
	op.Var("cursor", cursor)
	var respData struct {
		User *User
	}
	err = client.Execute(ctx, op, &respData)
	return respData.User, err
}

func UpdatePatchset(client *gqlclient.Client, ctx context.Context, id int32, status PatchsetStatus) (updatePatchset *Patchset, err error) {
	op := gqlclient.NewOperation("mutation updatePatchset ($id: Int!, $status: PatchsetStatus!) {\n\tupdatePatchset(id: $id, status: $status) {\n\t\tsubmitter {\n\t\t\tcanonicalName\n\t\t}\n\t\tstatus\n\t\tsubject\n\t}\n}\n")
	op.Var("id", id)
	op.Var("status", status)
	var respData struct {
		UpdatePatchset *Patchset
	}
	err = client.Execute(ctx, op, &respData)
	return respData.UpdatePatchset, err
}

A lists/model.go => lists/model.go +39 -0
@@ 0,0 1,39 @@
package lists

import (
	"context"

	"git.sr.ht/~rockorager/shtc"
	"git.sr.ht/~rockorager/shtc/widgets"
	"git.sr.ht/~rockorager/vaxis"
)

type Model struct {
	tab   *widgets.Tab
	lists []MailingList
}

func New() *Model {
	m := &Model{}

	return m
}

func (m *Model) Update(msg vaxis.Msg) {
	switch msg := msg.(type) {
	case *widgets.Tab:
		m.tab = msg
	case vaxis.Visible:
		if !msg {
			return
		}
		vaxis.PostCmd(shtc.FetchMailingLists{
			Context: context.Background(),
			From:    m,
		})
	case []MailingList:
		m.lists = msg
	}
}

func (m *Model) Draw(win vaxis.Window) {}

A lists/operations.graphql => lists/operations.graphql +59 -0
@@ 0,0 1,59 @@
query mailingLists($cursor: Cursor) {
  me {
    lists(cursor: $cursor) {
      ...lists
    }
  }
}

query mailingListsByUser($username: String!, $cursor: Cursor) {
  user(username: $username) {
    lists(cursor: $cursor) {
      ...lists
    }
  }
}

fragment lists on MailingListCursor {
  results {
    name
    description
    visibility
    owner {
      canonicalName
    }
  }
  cursor
}

# To get patches in ~rockorager/hutui you would pass rockorager and hutui
query listPatches($username: String!, $name: String!, $cursor: Cursor) {
  user(username: $username) {
    list(name: $name) {
      patches(cursor: $cursor) {
        results {
          id
          subject
          status
          created
          version
          prefix
          submitter {
            canonicalName
          }
        }
        cursor
      }
    }
  }
}

mutation updatePatchset($id: Int!, $status: PatchsetStatus!) {
  updatePatchset(id: $id, status: $status) {
    submitter {
      canonicalName
    }
    status
    subject
  }
}

A lists/schema.graphql => lists/schema.graphql +776 -0
@@ 0,0 1,776 @@
# This schema definition is available in the public domain, or under the terms
# of CC-0, at your choice.

"String of the format %Y-%m-%dT%H:%M:%SZ"
scalar Time
"Opaque string"
scalar Cursor
"""
URL from which some secondary data may be retrieved. You must provide the
same Authentication header to this address as you did to the GraphQL resolver
which provided it. The URL is not guaranteed to be consistent for an extended
length of time; applications should submit a new GraphQL query each time they
wish to access the data at the provided URL.
"""
scalar URL
scalar Upload

"Used to provide a human-friendly description of an access scope"
directive @scopehelp(details: String!) on ENUM_VALUE

"""
This is used to decorate fields which are only accessible with a personal
access token, and are not available to clients using OAuth 2.0 access tokens.
"""
directive @private on FIELD_DEFINITION

"""
This is used to decorate fields which are for internal use, and are not
available to normal API users.
"""
directive @internal on FIELD_DEFINITION

enum AccessScope {
  ACLS @scopehelp(details: "access control lists")
  EMAILS @scopehelp(details: "emails")
  LISTS @scopehelp(details: "mailing lists")
  PATCHES @scopehelp(details: "patches")
  PROFILE @scopehelp(details: "profile information")
  SUBSCRIPTIONS @scopehelp(details: "tracker & ticket subscriptions")
}

enum AccessKind {
  RO @scopehelp(details: "read")
  RW @scopehelp(details: "read and write")
}

"""
Decorates fields for which access requires a particular OAuth 2.0 scope with
read or write access.
"""
directive @access(scope: AccessScope!, kind: AccessKind!) on FIELD_DEFINITION

# https://semver.org
type Version {
  major: Int!
  minor: Int!
  patch: Int!

  """
  If this API version is scheduled for deprecation, this is the date on which
  it will stop working; or null if this API version is not scheduled for
  deprecation.
  """
  deprecationDate: Time
}

interface Entity {
  canonicalName: String!
}

"A registered user"
type User implements Entity {
  id: Int!
  created: Time!
  updated: Time!
  canonicalName: String!
  username: String!
  email: String!
  url: String
  location: String
  bio: String

  list(name: String!): MailingList @access(scope: LISTS, kind: RO)
  lists(cursor: Cursor): MailingListCursor! @access(scope: LISTS, kind: RO)
  emails(cursor: Cursor): EmailCursor! @access(scope: EMAILS, kind: RO)
  threads(cursor: Cursor): ThreadCursor! @access(scope: EMAILS, kind: RO)
  patches(cursor: Cursor): PatchsetCursor! @access(scope: PATCHES, kind: RO)
}

"A mailbox not associated with a registered user"
type Mailbox implements Entity {
  canonicalName: String!
  name: String!
  address: String!
}

enum Visibility {
  PUBLIC
  UNLISTED
  PRIVATE
}

type MailingList {
  id: Int!
  created: Time!
  updated: Time!
  name: String!
  owner: Entity! @access(scope: PROFILE, kind: RO)

  # Markdown
  description: String
  visibility: Visibility!

  """
  List of globs for permitted or rejected mimetypes on this list
  e.g. text/*
  """
  permitMime: [String!]!
  rejectMime: [String!]!

  "List of threads on this list in order of most recently bumped"
  threads(cursor: Cursor): ThreadCursor! @access(scope: EMAILS, kind: RO)
  "List of emails received on this list in reverse chronological order"
  emails(cursor: Cursor): EmailCursor! @access(scope: EMAILS, kind: RO)
  "List of patches received on this list in order of most recently bumped"
  patches(cursor: Cursor): PatchsetCursor! @access(scope: PATCHES, kind: RO)

  "True if an import operation is underway for this list"
  importing: Boolean!

  "The access that applies to this user for this list"
  access: ACL! @access(scope: ACLS, kind: RO)

  "The user's subscription for this list, if any"
  subscription: MailingListSubscription @access(scope: SUBSCRIPTIONS, kind: RO)

  "URLs to application/mbox archives for this mailing list"
  archive: URL!
  last30days: URL!

  #
  # The following resolvers are only available to the list owner:

  "Access control list entries for this mailing list"
  acl(cursor: Cursor): MailingListACLCursor! @access(scope: ACLS, kind: RO)

  defaultACL: GeneralACL!

  """
  Returns a list of mailing list webhook subscriptions. For clients
  authenticated with a personal access token, this returns all webhooks
  configured by all GraphQL clients for your account. For clients
  authenticated with an OAuth 2.0 access token, this returns only webhooks
  registered for your client.
  """
  webhooks(cursor: Cursor): WebhookSubscriptionCursor!

  "Returns details of a mailing list webhook subscription by its ID."
  webhook(id: Int!): WebhookSubscription
}

type OAuthClient {
  uuid: String!
}

enum WebhookEvent {
  LIST_CREATED @access(scope: LISTS, kind: RO)
  LIST_UPDATED @access(scope: LISTS, kind: RO)
  LIST_DELETED @access(scope: LISTS, kind: RO)
  EMAIL_RECEIVED @access(scope: EMAILS, kind: RO)
  PATCHSET_RECEIVED @access(scope: PATCHES, kind: RO)
}

interface WebhookSubscription {
  id: Int!
  events: [WebhookEvent!]!
  query: String!
  url: String!

  """
  If this webhook was registered by an authorized OAuth 2.0 client, this
  field is non-null.
  """
  client: OAuthClient @private

  "All deliveries which have been sent to this webhook."
  deliveries(cursor: Cursor): WebhookDeliveryCursor!

  "Returns a sample payload for this subscription, for testing purposes"
  sample(event: WebhookEvent!): String!
}

type UserWebhookSubscription implements WebhookSubscription {
  id: Int!
  events: [WebhookEvent!]!
  query: String!
  url: String!
  client: OAuthClient @private
  deliveries(cursor: Cursor): WebhookDeliveryCursor!
  sample(event: WebhookEvent!): String!
}

type MailingListWebhookSubscription implements WebhookSubscription {
  id: Int!
  events: [WebhookEvent!]!
  query: String!
  url: String!
  client: OAuthClient @private
  deliveries(cursor: Cursor): WebhookDeliveryCursor!
  sample(event: WebhookEvent!): String!

  list: MailingList!
}

type WebhookDelivery {
  uuid: String!
  date: Time!
  event: WebhookEvent!
  subscription: WebhookSubscription!
  requestBody: String!

  """
  These details are provided only after a response is received from the
  remote server. If a response is sent whose Content-Type is not text/*, or
  cannot be decoded as UTF-8, the response body will be null. It will be
  truncated after 64 KiB.
  """
  responseBody: String
  responseHeaders: String
  responseStatus: Int
}

interface WebhookPayload {
  uuid: String!
  event: WebhookEvent!
  date: Time!
}

type MailingListEvent implements WebhookPayload {
  uuid: String!
  event: WebhookEvent!
  date: Time!

  list: MailingList!
}

type EmailEvent implements WebhookPayload {
  uuid: String!
  event: WebhookEvent!
  date: Time!

  email: Email!
}

type PatchsetEvent implements WebhookPayload {
  uuid: String!
  event: WebhookEvent!
  date: Time!

  patchset: Patchset!
}

interface ACL {
  "Permission to browse or subscribe to emails"
  browse: Boolean!
  "Permission to reply to existing threads"
  reply: Boolean!
  "Permission to start new threads"
  post: Boolean!
  "Permission to moderate the list"
  moderate: Boolean!
}

"""
These ACLs are configured for specific entities, and may be used to expand or
constrain the rights of a participant.
"""
type MailingListACL implements ACL {
  id: Int!
  created: Time!
  list: MailingList! @access(scope: LISTS, kind: RO)
  entity: Entity! @access(scope: PROFILE, kind: RO)

  browse: Boolean!
  reply: Boolean!
  post: Boolean!
  moderate: Boolean!
}

"""
An ACL entry that applies "generally", for example the rights which apply to
all subscribers to a list.
"""
type GeneralACL implements ACL {
  browse: Boolean!
  reply: Boolean!
  post: Boolean!
  moderate: Boolean!
}

type Thread {
  created: Time!
  updated: Time!
  subject: String!
  replies: Int!
  participants: Int!
  sender: Entity!

  root: Email!

  list: MailingList! @access(scope: LISTS, kind: RO)

  "Replies to this thread, in chronological order"
  descendants(cursor: Cursor): EmailCursor!

  "A mailto: URI for replying to the latest message in this thread"
  mailto: String!

  "URL to an application/mbox archive of this thread"
  mbox: URL!

  """
  Thread parsed as a tree.

  The returned list is never empty. The first item is guaranteed to be the root
  message. The blocks are sorted in topological order.
  """
  blocks: [ThreadBlock!]!
}

"""
A block of text in an email thread.

Blocks are parts of a message's body that aren't quotes of the parent message.
A block can be a reply to a parent block, in which case the parentStart and
parentEnd fields indicate which part of the parent message is replied to. A
block can have replies, each of which will be represented by a block in the
children field.
"""
type ThreadBlock {
  "Unique identifier for this block."
  key: String!
  "The block's plain-text content."
  body: String!
  "Index of the parent block (if any) in Thread.blocks."
  parent: Int
  """
  Replies to this block.

  The list items are indexes into Thread.blocks.
  """
  children: [Int!]!

  "The email this block comes from."
  source: Email!
  "The range of this block in the source email body."
  sourceRange: ByteRange!

  """
  If this block is a reply to a particular chunk of the parent block, this
  field indicates the range of that chunk in the parent's email body.
  """
  parentRange: ByteRange
}

"""
A byte range.
"""
type ByteRange {
  "Inclusive start byte offset."
  start: Int!
  "Exclusive end byte offset."
  end: Int!
}

type Email {
  id: Int!

  """
  The entity which sent this email. Will be a User if it can be associated
  with an account, or a Mailbox otherwise.
  """
  sender: Entity!
  "Time we received this email (non-forgable)."
  received: Time!
  "Time given by Date header (forgable)."
  date: Time
  "The Subject header."
  subject: String!
  "The Message-ID header, without angle brackets."
  messageID: String!
  "The In-Reply-To header, if present, without angle brackets."
  inReplyTo: String

  """
  Provides the value (or values) of a specific header from this email. Note
  that the returned value is coerced to UTF-8 and may be lossy under certain
  circumstances.
  """
  header(want: String!): [String!]!
  "Retrieves the value of an address list header, such as To or Cc."
  addressList(want: String!): [Mailbox!]!
  "The decoded text/plain message part of the email, i.e. email body."
  body: String!
  "A URL from which the full raw message envelope may be downloaded."
  envelope: URL!

  thread: Thread!
  parent: Email
  patch: Patch

  patchset: Patchset @access(scope: PATCHES, kind: RO)
  list: MailingList! @access(scope: LISTS, kind: RO)
}

"""
Information parsed from the subject line of a patch, such that the following:

    [PATCH myproject v2 3/4] Add foo to bar

Will produce:

    index: 3
    count: 4
    version: 2
    prefix: "myproject"
    subject: "Add foo to bar"
"""
type Patch {
  index: Int
  count: Int
  version: Int
  prefix: String
  subject: String
}

enum PatchsetStatus {
  UNKNOWN
  PROPOSED
  NEEDS_REVISION
  SUPERSEDED
  APPROVED
  REJECTED
  APPLIED
}

type Patchset {
  id: Int!
  created: Time!
  updated: Time!
  subject: String!
  version: Int!
  prefix: String
  status: PatchsetStatus!
  submitter: Entity!

  coverLetter: Email @access(scope: EMAILS, kind: RO)
  thread: Thread! @access(scope: EMAILS, kind: RO)
  supersededBy: Patchset
  list: MailingList! @access(scope: LISTS, kind: RO)
  patches(cursor: Cursor): EmailCursor! @access(scope: EMAILS, kind: RO)
  tools: [PatchsetTool!]!

  "URL to an application/mbox archive of only the patches in this thread"
  mbox: URL!
}

enum ToolIcon {
  PENDING
  WAITING
  SUCCESS
  FAILED
  CANCELLED
}

"""
Used to add some kind of indicator for a third-party process associated with
a patchset, such as a CI service validating the change.
"""
type PatchsetTool {
  id: Int!
  created: Time!
  updated: Time!
  icon: ToolIcon!
  details: String!
  patchset: Patchset!
}

interface ActivitySubscription {
  id: Int!
  created: Time!
}

type MailingListSubscription implements ActivitySubscription {
  id: Int!
  created: Time!
  list: MailingList! @access(scope: LISTS, kind: RO)
}

"""
A cursor for enumerating ACL entries

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type MailingListACLCursor {
  results: [MailingListACL!]!
  cursor: Cursor
}

"""
A cursor for enumerating mailing lists

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type MailingListCursor {
  results: [MailingList!]!
  cursor: Cursor
}

"""
A cursor for enumerating threads

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type ThreadCursor {
  results: [Thread!]!
  cursor: Cursor
}

"""
A cursor for enumerating emails

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type EmailCursor {
  results: [Email!]!
  cursor: Cursor
}

"""
A cursor for enumerating patchsets

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type PatchsetCursor {
  results: [Patchset!]!
  cursor: Cursor
}

"""
A cursor for enumerating subscriptions

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type ActivitySubscriptionCursor {
  results: [ActivitySubscription!]!
  cursor: Cursor
}

"""
A cursor for enumerating a list of webhook deliveries

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type WebhookDeliveryCursor {
  results: [WebhookDelivery!]!
  cursor: Cursor
}

"""
A cursor for enumerating a list of webhook subscriptions

If there are additional results available, the cursor object may be passed
back into the same endpoint to retrieve another page. If the cursor is null,
there are no remaining results to return.
"""
type WebhookSubscriptionCursor {
  results: [WebhookSubscription!]!
  cursor: Cursor
}

type Query {
  "Returns API version information"
  version: Version!

  "Returns the authenticated user"
  me: User! @access(scope: PROFILE, kind: RO)

  "Looks up a specific user"
  user(username: String!): User @access(scope: PROFILE, kind: RO)

  "Looks up a specific email by its ID"
  email(id: Int!): Email @access(scope: EMAILS, kind: RO)
  """
  Looks up a specific email by its Message-ID header, including the angle
  brackets ('<' and '>').
  """
  message(messageID: String!): Email @access(scope: EMAILS, kind: RO)
  "Looks up a patchset by ID"
  patchset(id: Int!): Patchset @access(scope: EMAILS, kind: RO)

  "List of subscriptions of the authenticated user"
  subscriptions(cursor: Cursor): ActivitySubscriptionCursor
    @access(scope: SUBSCRIPTIONS, kind: RO)

  """
  Returns a list of user webhook subscriptions. For clients
  authenticated with a personal access token, this returns all webhooks
  configured by all GraphQL clients for your account. For clients
  authenticated with an OAuth 2.0 access token, this returns only webhooks
  registered for your client.
  """
  userWebhooks(cursor: Cursor): WebhookSubscriptionCursor!

  "Returns details of a user webhook subscription by its ID."
  userWebhook(id: Int!): WebhookSubscription

  """
  Returns information about the webhook currently being processed. This is
  not valid during normal queries over HTTP, and will return an error if used
  outside of a webhook context.
  """
  webhook: WebhookPayload!
}

# You may omit any fields to leave them unchanged.
# TODO: Allow users to change the name of a mailing list
input MailingListInput {
  description: String
  visibility: Visibility

  """
  List of globs for permitted or rejected mimetypes on this list
  e.g. text/*
  """
  permitMime: [String!]
  rejectMime: [String!]
}

# All fields are required
input ACLInput {
  browse: Boolean!
  reply: Boolean!
  post: Boolean!
  moderate: Boolean!
}

input UserWebhookInput {
  url: String!
  events: [WebhookEvent!]!
  query: String!
}

input MailingListWebhookInput {
  url: String!
  events: [WebhookEvent!]!
  query: String!
}

type Mutation {
  "Creates a new mailing list"
  createMailingList(
    name: String!
    description: String
    visibility: Visibility!
  ): MailingList! @access(scope: LISTS, kind: RW)

  "Updates a mailing list."
  updateMailingList(id: Int!, input: MailingListInput!): MailingList
    @access(scope: LISTS, kind: RW)

  "Deletes a mailing list"
  deleteMailingList(id: Int!): MailingList @access(scope: LISTS, kind: RW)

  "Adds or updates the ACL for a user on a mailing list"
  updateUserACL(listID: Int!, userID: Int!, input: ACLInput!): MailingListACL
    @access(scope: ACLS, kind: RW)

  "Adds or updates the ACL for an email address on a mailing list"
  updateSenderACL(
    listID: Int!
    address: String!
    input: ACLInput!
  ): MailingListACL @access(scope: ACLS, kind: RW)

  """
  Updates the default ACL for a mailing list, which applies to users and
  senders for whom a more specific ACL does not exist.
  """
  updateMailingListACL(listID: Int!, input: ACLInput!): MailingList
    @access(scope: ACLS, kind: RW)

  """
  Removes a mailing list ACL. Following this, the default mailing list ACL will
  apply to this user.
  """
  deleteACL(id: Int!): MailingListACL @access(scope: ACLS, kind: RW)

  "Updates the status of a patchset"
  updatePatchset(id: Int!, status: PatchsetStatus!): Patchset
    @access(scope: PATCHES, kind: RW)

  "Create a new patchset tool"
  createTool(patchsetID: Int!, details: String!, icon: ToolIcon!): PatchsetTool
    @access(scope: PATCHES, kind: RW)

  "Updates the status of a patchset tool by its ID"
  updateTool(id: Int!, details: String, icon: ToolIcon): PatchsetTool
    @access(scope: PATCHES, kind: RW)

  "Creates a mailing list subscription"
  mailingListSubscribe(listID: Int!): MailingListSubscription
    @access(scope: SUBSCRIPTIONS, kind: RW)

  "Deletes a mailing list subscription"
  mailingListUnsubscribe(listID: Int!): MailingListSubscription
    @access(scope: SUBSCRIPTIONS, kind: RW)

  "Imports a mail spool (must be in the Mbox format)"
  importMailingListSpool(listID: Int!, spool: Upload!): Boolean!
    @access(scope: LISTS, kind: RW)

  """
  Creates a new user webhook subscription. When an event from the
  provided list of events occurs, the 'query' parameter (a GraphQL query)
  will be evaluated and the results will be sent to the provided URL as the
  body of an HTTP POST request. The list of events must include at least one
  event, and no duplicates.

  This query is evaluated in the webhook context, such that query { webhook }
  may be used to access details of the event which trigged the webhook. The
  query may not make any mutations.
  """
  createUserWebhook(config: UserWebhookInput!): WebhookSubscription!

  """
  Deletes a user webhook. Any events already queued may still be
  delivered after this request completes. Clients authenticated with a
  personal access token may delete any webhook registered for their account,
  but authorized OAuth 2.0 clients may only delete their own webhooks.
  Manually deleting a webhook configured by a third-party client may cause
  unexpected behavior with the third-party integration.
  """
  deleteUserWebhook(id: Int!): WebhookSubscription!

  "Creates a new mailing list webhook."
  createMailingListWebhook(
    listId: Int!
    config: MailingListWebhookInput!
  ): WebhookSubscription!

  "Deletes a mailing list webhook."
  deleteMailingListWebhook(id: Int!): WebhookSubscription!

  triggerUserEmailWebhooks(emailId: Int!): Email! @internal
  triggerListEmailWebhooks(listId: Int!, emailId: Int!): Email! @internal

  """
  Deletes the authenticated user's account. Internal use only.
  """
  deleteUser: Int! @internal
}

M messages.go => messages.go +5 -0
@@ 6,6 6,11 @@ type OpenedRepository struct {
	Readme string
}

type GitLog struct {
	Name string
	Log  []string
}

type Cmdline struct {
	Args []string
}

M ui/ui.go => ui/ui.go +4 -0
@@ 7,6 7,7 @@ import (
	"git.sr.ht/~rockorager/shtc"
	"git.sr.ht/~rockorager/shtc/config"
	"git.sr.ht/~rockorager/shtc/git"
	"git.sr.ht/~rockorager/shtc/lists"
	"git.sr.ht/~rockorager/shtc/log"
	"git.sr.ht/~rockorager/shtc/widgets"
	"git.sr.ht/~rockorager/vaxis"


@@ 19,6 20,7 @@ type Model struct {
	tabs    *widgets.TabView
	cmdline *textinput.Model
	git     *git.Model
	lists   *lists.Model
	bypass  bool
}



@@ 38,6 40,8 @@ func (m *Model) Update(msg vaxis.Msg) {
		m.tabs = widgets.NewTabView()
		m.git = git.NewModel()
		m.tabs.NewTab("git", m.git)
		m.lists = lists.New()
		m.tabs.NewTab("lists", m.lists)
		if config.UI.ChatCmd != "" {
			chat := term.New(m)
			chatCmd := exec.Command(config.UI.ChatCmd)