feat(projects): link CRM companies and git authors to projects

Add List/Add/Remove project contacts and CLI:
contacts list|add|remove|link-git|link-authors.
Contacts tab (CRM) is separate from ProjectTeam portal users.
This commit is contained in:
2026-07-26 13:25:50 +01:00
parent 179f30644d
commit aa79a83c0a
2 changed files with 348 additions and 0 deletions
+313
View File
@@ -2,6 +2,8 @@ package main
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
@@ -24,6 +26,7 @@ func init() {
projectsCmd.AddCommand(prjCreateCmd())
projectsCmd.AddCommand(prjUpdateCmd())
projectsCmd.AddCommand(prjDeleteCmd())
projectsCmd.AddCommand(prjContactsCmd())
}
func prjListCmd() *cobra.Command {
@@ -259,6 +262,316 @@ func derefString(p *string) string {
return *p
}
func prjContactsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "contacts",
Short: "CRM contacts linked to a project (companies + persons)",
Long: `OnlyOffice Projects have two participant layers:
- Team (portal users) — ProjectTeam.aspx
- Contacts (CRM companies/persons) — linked via this API
Link employers/clients and people who worked on the engagement (e.g. git authors).`,
}
cmd.AddCommand(prjContactsListCmd())
cmd.AddCommand(prjContactsAddCmd())
cmd.AddCommand(prjContactsRemoveCmd())
cmd.AddCommand(prjContactsLinkGitCmd())
cmd.AddCommand(prjContactsLinkAuthorsCmd())
return cmd
}
func prjContactsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list PROJECT_ID",
Short: "List CRM contacts linked to the project",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
list, err := c.ListProjectContacts(cmd.Context(), pid)
if err != nil {
return err
}
rows := make([]map[string]any, 0, len(list))
for _, row := range list {
rows = append(rows, map[string]any{
"id": row["id"],
"name": row["displayName"],
"company": row["isCompany"],
"companyOf": row["companyName"],
})
}
printTable([]string{"id", "name", "company", "companyOf"}, rows)
return nil
},
}
}
func prjContactsAddCmd() *cobra.Command {
return &cobra.Command{
Use: "add PROJECT_ID CONTACT_ID [CONTACT_ID...]",
Short: "Link CRM contact(s) to the project",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
for _, raw := range args[1:] {
cid, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("contact id %q: %w", raw, err)
}
if _, err := c.AddProjectContact(cmd.Context(), pid, cid); err != nil {
return fmt.Errorf("add %d: %w", cid, err)
}
printObject(map[string]any{"project_id": pid, "contact_id": cid, "linked": true})
}
return nil
},
}
}
func prjContactsRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove PROJECT_ID CONTACT_ID [CONTACT_ID...]",
Short: "Unlink CRM contact(s) from the project",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
for _, raw := range args[1:] {
cid, err := strconv.Atoi(raw)
if err != nil {
return fmt.Errorf("contact id %q: %w", raw, err)
}
if err := c.RemoveProjectContact(cmd.Context(), pid, cid); err != nil {
return fmt.Errorf("remove %d: %w", cid, err)
}
printObject(map[string]any{"project_id": pid, "contact_id": cid, "unlinked": true})
}
return nil
},
}
}
func prjContactsLinkGitCmd() *cobra.Command {
var gitRoot string
var minCommits int
var dryRun bool
var companyIDs []int
cmd := &cobra.Command{
Use: "link-git PROJECT_ID",
Short: "Link CRM persons matched from git shortlog (+ optional companies)",
Long: `Runs git shortlog -sne --all in --git-root, matches author emails to CRM
persons (FindPersonByEmail), and links found contacts to the project.
Does not create new persons — approve/apply them via catalog first.
Also links --company-id contacts (e.g. Acme + end-client company).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if gitRoot == "" && len(companyIDs) == 0 {
return fmt.Errorf("need --git-root and/or --company-id")
}
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
var authors []gitAuthor
if gitRoot != "" {
authors, err = gitShortlogAuthors(gitRoot, minCommits)
if err != nil {
return err
}
}
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
},
}
cmd.Flags().StringVar(&gitRoot, "git-root", "", "local git repository path")
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
return cmd
}
func prjContactsLinkAuthorsCmd() *cobra.Command {
var authorsFile string
var minCommits int
var dryRun bool
var companyIDs []int
cmd := &cobra.Command{
Use: "link-authors PROJECT_ID",
Short: "Link CRM persons from a git shortlog file (+ companies)",
Long: `Same as link-git, but reads authors from a file produced by:
git shortlog -sne --all > authors.txt
# or via ssh:
ssh ops-host 'git -C /path shortlog -sne --all' > authors.txt
Then: oo projects contacts link-authors 59 --from authors.txt --company-id 9`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if authorsFile == "" && len(companyIDs) == 0 {
return fmt.Errorf("need --from and/or --company-id")
}
c, err := newOO(cmd)
if err != nil {
return err
}
pid, err := strconv.Atoi(args[0])
if err != nil {
return fmt.Errorf("project id: %w", err)
}
var authors []gitAuthor
if authorsFile != "" {
b, err := os.ReadFile(authorsFile)
if err != nil {
return err
}
authors = parseShortlog(string(b), minCommits)
}
return linkProjectPeople(cmd, c, pid, authors, companyIDs, dryRun)
},
}
cmd.Flags().StringVar(&authorsFile, "from", "", "path to git shortlog -sne output")
cmd.Flags().IntVar(&minCommits, "min-commits", 3, "ignore authors below this commit count")
cmd.Flags().IntSliceVar(&companyIDs, "company-id", nil, "CRM company contact ids to link")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print planned links only")
return cmd
}
func linkProjectPeople(cmd *cobra.Command, c *onlyoffice.Client, pid int, authors []gitAuthor, companyIDs []int, dryRun bool) error {
ctx := cmd.Context()
existing, err := c.ListProjectContacts(ctx, pid)
if err != nil {
return err
}
have := map[int]struct{}{}
for _, row := range existing {
have[int(flexIDFloat(row["id"]))] = struct{}{}
}
linked, skipped := 0, 0
for _, cid := range companyIDs {
if _, ok := have[cid]; ok {
skipped++
continue
}
if dryRun {
printObject(map[string]any{"would_link_company": cid})
linked++
continue
}
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
return fmt.Errorf("company %d: %w", cid, err)
}
have[cid] = struct{}{}
linked++
printObject(map[string]any{"linked_company": cid})
}
for _, a := range authors {
p, err := c.FindPersonByEmail(ctx, a.Email)
if err != nil {
return err
}
if p == nil {
printObject(map[string]any{
"skipped_email": a.Email, "name": a.Name, "commits": a.Commits, "reason": "no_crm_person",
})
skipped++
continue
}
cid := int(flexIDFloat(p["id"]))
if _, ok := have[cid]; ok {
skipped++
continue
}
if dryRun {
printObject(map[string]any{"would_link_person": cid, "email": a.Email, "commits": a.Commits})
linked++
continue
}
if _, err := c.AddProjectContact(ctx, pid, cid); err != nil {
return fmt.Errorf("person %d (%s): %w", cid, a.Email, err)
}
have[cid] = struct{}{}
linked++
printObject(map[string]any{"linked_person": cid, "email": a.Email, "commits": a.Commits})
}
printObject(map[string]any{"project_id": pid, "linked": linked, "skipped": skipped, "dry_run": dryRun})
return nil
}
func parseShortlog(text string, minCommits int) []gitAuthor {
var authors []gitAuthor
seen := map[string]struct{}{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
n, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
if n < minCommits {
continue
}
rest := parts[1]
lt := strings.LastIndex(rest, "<")
gt := strings.LastIndex(rest, ">")
if lt < 0 || gt <= lt {
continue
}
email := strings.ToLower(strings.TrimSpace(rest[lt+1 : gt]))
name := strings.TrimSpace(rest[:lt])
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
authors = append(authors, gitAuthor{Commits: n, Name: name, Email: email})
}
return authors
}
type gitAuthor struct {
Commits int
Name string
Email string
}
func gitShortlogAuthors(root string, minCommits int) ([]gitAuthor, error) {
cmd := exec.Command("git", "-C", root, "shortlog", "-sne", "--all")
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("git shortlog in %s: %w", root, err)
}
return parseShortlog(string(out), minCommits), nil
}
func derefInt(p *int) int {
if p == nil {
return 0