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:
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ func init() {
|
|||||||
projectsCmd.AddCommand(prjCreateCmd())
|
projectsCmd.AddCommand(prjCreateCmd())
|
||||||
projectsCmd.AddCommand(prjUpdateCmd())
|
projectsCmd.AddCommand(prjUpdateCmd())
|
||||||
projectsCmd.AddCommand(prjDeleteCmd())
|
projectsCmd.AddCommand(prjDeleteCmd())
|
||||||
|
projectsCmd.AddCommand(prjContactsCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
func prjListCmd() *cobra.Command {
|
func prjListCmd() *cobra.Command {
|
||||||
@@ -259,6 +262,316 @@ func derefString(p *string) string {
|
|||||||
return *p
|
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 {
|
func derefInt(p *int) int {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user