Powered by AppSignal & Oban Pro

Pairing Patterns

pairing.livemd

Pairing Patterns

Purpose

This notebook analyzes pairing patterns from YouTrack issues. It identifies which team members frequently work together, how pairing trends evolve over time, and how pairing is distributed across workstreams and projects.

Setup

Mix.install([
  {:youtrack, path: "./youtrack"},
  {:kino, "~> 0.14"},
  {:kino_vega_lite, "~> 0.1"}
])

alias VegaLite, as: Vl
alias Youtrack.{Client, Fields, PairingAnalysis, Workstreams, WorkstreamsLoader}

Inputs

base_url_in =
  Kino.Input.text(
    "YouTrack base URL",
    default: System.get_env("YOUTRACK_BASE_URL") || "https://your-instance.youtrack.cloud"
  )

token_in =
  Kino.Input.password(
    "Permanent token",
    default: System.get_env("YOUTRACK_TOKEN") || ""
  )

base_query_in =
  Kino.Input.text(
    "YouTrack base query (e.g. project: MYPROJECT)",
    default: System.get_env("YOUTRACK_BASE_QUERY") || "project: MYPROJECT"
  )

days_back_in =
  Kino.Input.number(
    "Days back to fetch",
    default: String.to_integer(System.get_env("YOUTRACK_DAYS_BACK") || "90")
  )

assignees_field_in =
  Kino.Input.text(
    "Assignees field name",
    default: System.get_env("YOUTRACK_ASSIGNEES_FIELD") || "Assignee"
  )

project_prefix_in =
  Kino.Input.text(
    "Filter by issue ID prefix (leave empty for all)",
    default: System.get_env("YOUTRACK_PROJECT_PREFIX") || ""
  )

excluded_logins_in =
  Kino.Input.text(
    "Excluded logins (comma-separated)",
    default: System.get_env("YOUTRACK_EXCLUDED_LOGINS") || ""
  )

include_substreams_in =
  Kino.Input.checkbox(
    "Include substreams (expand to parent workstreams)",
    default: true)

unplanned_tag_in =
  Kino.Input.text(
    "Unplanned work tag (e.g., 'on the ankles')",
    default: System.get_env("YOUTRACK_UNPLANNED_TAG") || "on the ankles")

Kino.Layout.grid(
  [
    base_url_in,
    token_in,
    base_query_in,
    days_back_in,
    assignees_field_in,
    project_prefix_in,
    excluded_logins_in,
    include_substreams_in,
    unplanned_tag_in
  ],
  columns: 2
)

Workstreams Config

# Load workstreams from file
{workstream_rules, workstreams_path} = WorkstreamsLoader.load_from_default_paths()

if workstreams_path do
  Kino.render(Kino.Markdown.new("Loading workstreams from `#{workstreams_path}`"))
else
  Kino.render(Kino.Markdown.new("⚠️ No workstreams.yaml found, using empty config"))
end

Kino.nothing()

Pairing Analysis

PairingAnalysis is now in the youtrack library.

Fetch Issues

base_url = Kino.Input.read(base_url_in) |> String.trim()
token = Kino.Input.read(token_in) |> String.trim()
base_query = Kino.Input.read(base_query_in) |> String.trim()
days_back = Kino.Input.read(days_back_in)
assignees_field = Kino.Input.read(assignees_field_in) |> String.trim()
project_prefix = Kino.Input.read(project_prefix_in) |> String.trim()

excluded_logins =
  Kino.Input.read(excluded_logins_in)
  |> String.split(",", trim: true)
  |> Enum.map(&String.trim/1)

today = Date.utc_today() |> Date.to_iso8601()
start_date = Date.utc_today() |> Date.add(-days_back) |> Date.to_iso8601()
query = "#{base_query} updated: #{start_date} .. #{today}"

Kino.render(Kino.Markdown.new("**Query:** `#{query}`"))

req = Client.new!(base_url, token)
raw_issues = Client.fetch_issues!(req, query)

issues =
  if project_prefix == "" do
    raw_issues
  else
    Enum.filter(raw_issues, fn %{"idReadable" => id} ->
      String.starts_with?(id, project_prefix)
    end)
  end

Kino.Markdown.new("Fetched **#{length(issues)}** issues.")

Extract Pairing Data

include_substreams? = Kino.Input.read(include_substreams_in)
unplanned_tag = Kino.Input.read(unplanned_tag_in) |> String.trim()

pair_records =
  PairingAnalysis.extract_pairs(issues,
    assignees_field: assignees_field,
    excluded_logins: excluded_logins,
    workstream_rules: workstream_rules,
    include_substreams: include_substreams?,
    unplanned_tag: unplanned_tag
  )

paired_issues = pair_records |> Enum.map(& &1.issue_id) |> Enum.uniq() |> length()
total_issues = length(issues)
unplanned_pairs = Enum.count(pair_records, & &1.is_unplanned)

Kino.Markdown.new("""
**Pairing summary:**
- Total issues: #{total_issues}
- Issues with 2+ assignees: #{paired_issues} (#{Float.round(paired_issues / max(total_issues, 1) * 100, 1)}%)
- Total pair occurrences: #{length(pair_records)}
- Unplanned pair occurrences: #{unplanned_pairs} (#{Float.round(unplanned_pairs / max(length(pair_records), 1) * 100, 1)}%)
""")

Pair Matrix Heatmap

matrix_data = PairingAnalysis.pair_matrix(pair_records)

Vl.new(width: 500, height: 500, title: "Pair Matrix")
|> Vl.data_from_values(matrix_data)
|> Vl.mark(:rect, tooltip: true)
|> Vl.encode_field(:x, "person_a", type: :nominal, title: "Person", sort: :ascending)
|> Vl.encode_field(:y, "person_b", type: :nominal, title: "Person", sort: :ascending)
|> Vl.encode_field(:color, "count",
  type: :quantitative,
  title: "Times paired",
  scale: [scheme: "blues"]
)
|> Vl.encode(:tooltip, [
  [field: "person_a", type: :nominal, title: "Person A"],
  [field: "person_b", type: :nominal, title: "Person B"],
  [field: "count", type: :quantitative, title: "Times paired"]
])

Pairing Trend Over Time

trend_data = PairingAnalysis.trend_by_week(pair_records)

Vl.new(width: 700, height: 300, title: "Pairing Trend by Week")
|> Vl.data_from_values(trend_data)
|> Vl.layers([
  Vl.new()
  |> Vl.mark(:bar, opacity: 0.6)
  |> Vl.encode_field(:x, "week", type: :temporal, title: "Week")
  |> Vl.encode_field(:y, "pair_count", type: :quantitative, title: "Pair occurrences"),
  Vl.new()
  |> Vl.mark(:line, color: "red", point: true)
  |> Vl.encode_field(:x, "week", type: :temporal)
  |> Vl.encode_field(:y, "unique_pairs", type: :quantitative, title: "Unique pairs")
])
|> Vl.encode(:tooltip, [
  [field: "week", type: :temporal, title: "Week"],
  [field: "pair_count", type: :quantitative, title: "Pair occurrences"],
  [field: "unique_pairs", type: :quantitative, title: "Unique pairs"]
])

Pairing by Workstream

workstream_data = PairingAnalysis.by_workstream(pair_records)

Vl.new(width: 500, height: 300, title: "Pairing by Workstream")
|> Vl.data_from_values(workstream_data)
|> Vl.mark(:bar, tooltip: true)
|> Vl.encode_field(:x, "workstream", type: :nominal, title: "Workstream", sort: "-y")
|> Vl.encode_field(:y, "pair_count", type: :quantitative, title: "Pair occurrences")
|> Vl.encode_field(:color, "unique_pairs",
  type: :quantitative,
  title: "Unique pairs",
  scale: [scheme: "oranges"]
)
|> Vl.encode(:tooltip, [
  [field: "workstream", type: :nominal, title: "Workstream"],
  [field: "pair_count", type: :quantitative, title: "Pair occurrences"],
  [field: "unique_pairs", type: :quantitative, title: "Unique pairs"]
])

Top Pairs

top_pairs =
  pair_records
  |> Enum.frequencies_by(&{&1.person_a, &1.person_b})
  |> Enum.sort_by(fn {_, count} -> count end, :desc)
  |> Enum.take(15)
  |> Enum.map(fn {{a, b}, count} -> %{pair: "#{a} + #{b}", count: count} end)

Kino.DataTable.new(top_pairs, name: "Top 15 Pairs")

Firefighter Detection

Identifies team members who handle a disproportionate amount of unplanned/interrupt work.

By Person

firefighter_persons = PairingAnalysis.firefighters_by_person(pair_records)

firefighter_chart =
  Vl.new(width: 600, height: 300, title: "Firefighters: Unplanned Work by Person")
  |> Vl.data_from_values(firefighter_persons)
  |> Vl.mark(:bar, tooltip: true)
  |> Vl.encode_field(:x, "person", type: :nominal, title: "Person", sort: "-y")
  |> Vl.encode_field(:y, "unplanned", type: :quantitative, title: "Unplanned Pair Occurrences")
  |> Vl.encode_field(:color, "unplanned_pct",
    type: :quantitative,
    title: "Unplanned %",
    scale: [scheme: "oranges"]
  )
  |> Vl.encode(:tooltip, [
    [field: "person", type: :nominal, title: "Person"],
    [field: "total", type: :quantitative, title: "Total"],
    [field: "unplanned", type: :quantitative, title: "Unplanned"],
    [field: "unplanned_pct", type: :quantitative, title: "Unplanned %"]
  ])

Kino.render(Kino.VegaLite.new(firefighter_chart))
Kino.DataTable.new(firefighter_persons, name: "Firefighters by Person")

By Pair

firefighter_pairs = PairingAnalysis.firefighters_by_pair(pair_records)

# Top 15 pairs by unplanned count
top_firefighter_pairs = Enum.take(firefighter_pairs, 15)

firefighter_pair_chart =
  Vl.new(width: 600, height: 300, title: "Firefighter Pairs: Top 15 by Unplanned Work")
  |> Vl.data_from_values(top_firefighter_pairs)
  |> Vl.mark(:bar, tooltip: true, color: "orangered")
  |> Vl.encode_field(:x, "pair", type: :nominal, title: "Pair", sort: "-y")
  |> Vl.encode_field(:y, "unplanned", type: :quantitative, title: "Unplanned Occurrences")
  |> Vl.encode(:tooltip, [
    [field: "pair", type: :nominal, title: "Pair"],
    [field: "total", type: :quantitative, title: "Total"],
    [field: "unplanned", type: :quantitative, title: "Unplanned"],
    [field: "unplanned_pct", type: :quantitative, title: "Unplanned %"]
  ])

Kino.render(Kino.VegaLite.new(firefighter_pair_chart))
Kino.DataTable.new(top_firefighter_pairs, name: "Firefighter Pairs")

Interrupt Frequency Over Time

Aggregate Trend

interrupt_trend = PairingAnalysis.interrupt_trend_by_week(pair_records)

interrupt_trend_chart =
  Vl.new(width: 700, height: 300, title: "Interrupt Frequency Over Time (Aggregate)")
  |> Vl.data_from_values(interrupt_trend)
  |> Vl.mark(:line, point: true, color: "orangered")
  |> Vl.encode_field(:x, "week", type: :temporal, title: "Week")
  |> Vl.encode_field(:y, "interrupt_count", type: :quantitative, title: "Interrupt Count")
  |> Vl.encode(:tooltip, [
    [field: "week", type: :temporal, title: "Week"],
    [field: "interrupt_count", type: :quantitative, title: "Interrupts"]
  ])

Kino.VegaLite.new(interrupt_trend_chart)

Per Person Trend

interrupt_by_person = PairingAnalysis.interrupt_trend_by_person(pair_records)

if length(interrupt_by_person) > 0 do
  person_interrupt_chart =
    Vl.new(width: 700, height: 400, title: "Interrupt Frequency by Person Over Time")
    |> Vl.data_from_values(interrupt_by_person)
    |> Vl.mark(:line, point: true)
    |> Vl.encode_field(:x, "week", type: :temporal, title: "Week")
    |> Vl.encode_field(:y, "interrupt_count", type: :quantitative, title: "Interrupt Count")
    |> Vl.encode_field(:color, "person", type: :nominal, title: "Person")
    |> Vl.encode(:tooltip, [
      [field: "person", type: :nominal, title: "Person"],
      [field: "week", type: :temporal, title: "Week"],
      [field: "interrupt_count", type: :quantitative, title: "Interrupts"]
    ])

  Kino.VegaLite.new(person_interrupt_chart)
else
  Kino.Markdown.new("*No unplanned pair occurrences found.*")
end