From aa79a83c0aa7735655c74882b079015d6693a689 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 26 Jul 2026 13:25:50 +0100 Subject: [PATCH] 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. --- cmd/oo/projects.go | 313 ++++++++++++++++++++++++++++++++++++++++++++ project_contacts.go | 35 +++++ 2 files changed, 348 insertions(+) create mode 100644 project_contacts.go diff --git a/cmd/oo/projects.go b/cmd/oo/projects.go index fc1a405..6299635 100644 --- a/cmd/oo/projects.go +++ b/cmd/oo/projects.go @@ -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 diff --git a/project_contacts.go b/project_contacts.go new file mode 100644 index 0000000..1f95aa4 --- /dev/null +++ b/project_contacts.go @@ -0,0 +1,35 @@ +package onlyoffice + +import ( + "context" + "fmt" + "net/url" + "strconv" +) + +// ListProjectContacts returns CRM persons/companies linked to a project. +// GET /api/2.0/crm/contact/project/{projectid} +func (c *Client) ListProjectContacts(ctx context.Context, projectID int) ([]map[string]any, error) { + return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/crm/contact/project/%d", projectID)) +} + +// AddProjectContact links a CRM contact (person or company) to a project. +// POST /api/2.0/project/{projectid}/contact form: contactId= +func (c *Client) AddProjectContact(ctx context.Context, projectID, contactID int) (map[string]any, error) { + fields := url.Values{} + fields.Set("contactId", strconv.Itoa(contactID)) + return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%d/contact", projectID), fields) +} + +// RemoveProjectContact unlinks a CRM contact from a project. +// DELETE /api/2.0/project/{projectid}/contact?contactId= +func (c *Client) RemoveProjectContact(ctx context.Context, projectID, contactID int) error { + _, err := c.deleteReq(ctx, fmt.Sprintf("/api/2.0/project/%d/contact?contactId=%d", projectID, contactID)) + return err +} + +// GetProjectTeam returns portal users on the project team. +// GET /api/2.0/project/{projectid}/team +func (c *Client) GetProjectTeam(ctx context.Context, projectID int) ([]map[string]any, error) { + return c.ResponseArray(ctx, fmt.Sprintf("/api/2.0/project/%d/team", projectID)) +}