DEV Community

Cover image for Microsoft Graph to Jira Assets User Ingestion
Mihai Perdum
Mihai Perdum

Posted on Originally published at leanzero.net

Microsoft Graph to Jira Assets User Ingestion

Microsoft Graph to Jira Assets User Ingestion

The ingestion system consists of three main components:

  1. Microsoft Graph API Client - Handles OAuth 2.0 authentication and fetches user data including manager information
  2. Jira Cloud Assets Client - Interacts with Jira Service Management Assets API using workspace tokens
  3. Ingestion Engine - Coordinates entire process including user matching, change detection, object lookups, and update logic

The system processes users in a streaming fashion:

  • Fetches users from Microsoft Graph API
  • Matches them against existing assets using multiple criteria
  • Detects changes while preserving existing data
  • Looks up object references (Company, Department, License)
  • Updates or creates users with proper error handling

Key Challenges Addressed

Atlassian's Jira Service Management (JSM) Assets API lacks comprehensive public documentation for certain operations. This implementation provides practical solutions for:

  • Object Reference Attributes (Type 1) - AQL responses return only objectTypeAttributeId, requiring ID-to-name mapping cache
  • User Type Attributes (Type 2) - Microsoft Graph provides email, but Jira Assets requires accountId, requiring separate Jira REST API lookup
  • Business Rule Restrictions - Some object references cannot be used with certain asset types due to schema constraints
  • Partial Update Failures - Attribute-level errors require retry mechanism with alternative object matches
  • Data Preservation - Microsoft Graph is the authority, but should never clear existing valid data

Architecture

Client Initialization

class MicrosoftGraphUserIngestion {
  constructor(options = {}) {
    this.options = {
      dryRun: options.dryRun || false,
      preview: options.preview || false,
      limit: options.limit || 0,
      debug: options.debug || false,
      mappingFile: options.mapping || null,
      schemaId: options.schemaId || 
        parseInt(process.env.OBJECT_SCHEMA_ID) || DEFAULT_SCHEMA_ID,
      objectTypeId: options.objectTypeId || 
        parseInt(process.env.OBJECT_TYPE_ID) || DEFAULT_OBJECT_TYPE_ID,
      filter: "userType eq 'Member'"
    };

    this.stats = {
      startTime: null,
      endTime: null,
      totalUsersFetched: 0,
      existingUsersMatched: 0,
      newUsersCreated: 0,
      existingUsersUpdated: 0,
      usersSkipped: 0,
      usersFailed: 0,
      errors: [],
    };

    this.graphApiClient = new MicrosoftGraphApiClient(
      process.env.MS_CLIENT_ID,
      process.env.MS_TENANT_ID,
      process.env.MS_CLIENT_SECRET,
    );

    this.cloudApiClient = new CloudApiClient(
      process.env.WORKSPACE_ID,
      process.env.CLOUD_API_TOKEN,
      process.env.CLOUD_BASE_URL || "https://api.atlassian.com",
    );

    this.attributeIdToNameMap = new Map();
    this.attributeIdToTypeInfoMap = new Map();
    this.existingUsersMap = new Map();

    this.userObjectCache = new Map();
    this.departmentObjectCache = new Map();
    this.licenseObjectCache = new Map();
    this.companyObjectCache = new Map();
    this.locationObjectCache = new Map();
  }
}
Enter fullscreen mode Exit fullscreen mode

Caching Strategy

Multiple cache maps are implemented for performance and fallback support:

// ID-to-name mapping - enables attribute lookups by name
this.attributeIdToNameMap = new Map();
this.attributeIdToTypeInfoMap = new Map();

// Object lookup caches - stores ALL potential matches for fallback
this.companyObjectCache = new Map();
this.departmentObjectCache = new Map();
this.licenseObjectCache = new Map();
this.locationObjectCache = new Map();

// User lookup cache - avoid repeated Jira REST API calls
this.userObjectCache = new Map();
Enter fullscreen mode Exit fullscreen mode

Understanding Jira Assets Attribute Types

Jira Assets uses three main attribute types that require different handling:

Type 0 (Text/String Attributes)

Simple string values like Name, Email, Title, Location (as text). Direct read/write with string values. No special handling required.

Type 1 (Object Reference Attributes)

References to other Assets objects like Company, Department, License. Most complex to handle!

Problem 1: AQL returns minimal information When querying Assets with AQL, response only includes objectTypeAttributeId and NOT the full attribute object with name field.

Example AQL Response Structure:

{
  "objectEntries": [
    {
      "id": "12345",
      "objectTypeAttributeId": "1486",
      "objectAttributeValues": [
        {
          "value": "UD-14341",
          "displayValue": "Example Company Inc."
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note: The objectTypeAttributeId (1486) tells us this is a Company attribute, but we don't know the attribute NAME without additional lookup.

Problem 2: Different values needed for comparison vs. update

  • For COMPARISON: We need displayValue (object name) to match against Microsoft Graph data
  • For UPDATE: We need value (object ID/key) to write to asset

Example Asset State:

{
  "attributes": [
    {
      "objectTypeAttributeId": "1486",
      "objectAttributeValues": [
        {
          "value": "UD-14341",        // This is the ID we write
          "displayValue": "Example Company Inc."  // This is the name we compare
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Type 2 (User Attributes)

References to Jira Platform users by accountId. The challenge:

Problem: Email to AccountId Mapping Microsoft Graph provides email addresses (e.g., user@example.com), but Jira Assets requires accountId (e.g., 70121:abc-123-def) for User type attributes.

Solution: Separate Jira REST API Lookup We need to call Jira REST API to look up user by email and retrieve their accountId.

Solution 1: ID-to-Name Mapping Cache

Since AQL responses only return objectTypeAttributeId, we need to build a cache that maps attribute IDs to their names. This enables us to look up attributes by name instead of ID.

Implementation Code

// First, fetch all attributes and build ID-to-name mapping
const attributes = await this.cloudApiClient.getObjectTypeAttributes(
  this.options.objectTypeId
);

this.attributeIdToNameMap = new Map();
this.attributeIdToTypeInfoMap = new Map();

for (const attr of attributes) {
  this.attributeIdToNameMap.set(attr.id, attr.name);
  this.attributeIdToTypeInfoMap.set(attr.id, {
    name: attr.name,
    type: attr.type,    // Type codes: 0=Text, 1=Object, 2=User
  });
}
Enter fullscreen mode Exit fullscreen mode

Using the Cache

getAttributeValue(asset, attributeName) {
  // Find attribute by ID using ID-to-name mapping
  const attr = asset.attributes.find((a) => {
    const attrName = this.attributeIdToNameMap[a.objectTypeAttributeId];
    return attrName && attrName.toLowerCase() === attributeName.toLowerCase();
  });

  if (!attr || !attr.objectAttributeValues || 
      attr.objectAttributeValues.length === 0) {
    return "";
  }

  return attr.objectAttributeValues[0].value;
}
Enter fullscreen mode Exit fullscreen mode

Type 1 Attributes - Return DisplayValue for Comparison

For Object Reference attributes, we need to return displayValue instead of value for comparison:

getAttributeValue(asset, attributeName) {
  // Find attribute by ID
  const attr = asset.attributes.find((a) => {
    const attrName = this.attributeIdToNameMap[a.objectTypeAttributeId];
    return attrName && attrName.toLowerCase() === attributeName.toLowerCase();
  });

  if (!attr || !attr.objectAttributeValues || 
      attr.objectAttributeValues.length === 0) {
    return "";
  }

  const attrId = attr.objectTypeAttributeId;
  const attrTypeInfo = this.attributeIdToTypeInfoMap[attrId];

  let returnValue = attr.objectAttributeValues[0].value;

  // For Object References (Type 1), return displayValue for comparison
  if (attrTypeInfo && attrTypeInfo.type === 1 && 
      attr.objectAttributeValues[0].displayValue) {
    returnValue = attr.objectAttributeValues[0].displayValue;
    // This enables: "Example Company" (from Assets) === "Example Company" (from Graph)
  }

  return returnValue;
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters

Without this fix: Comparison would fail because:

  • Asset value: "UD-14341" (object ID)
  • Graph value: "Example Company" (company name)
  • Result: "UD-14341" !== "Example Company" (incorrect - change detected when shouldn't be!)

With this fix: Comparison works correctly:

  • Asset value: "Example Company" (display name)
  • Graph value: "Example Company" (company name)
  • Result: "Example Company" === "Example Company" (correct - no false change!)

Solution 2: Object Reference Attribute Lookup

For Type 1 attributes like Company, Department, License, we need to look up the object by name and use its ID for updates.

Implementation Code

async lookupObjectId(attributeName, value) {
  if (!value) {
    return "";
  }

  // Cache lookup to avoid repeated AQL queries
  const cacheKey = `${attributeName}:${value}`;

  // Check if we have cached potential matches
  if (this.companyObjectCache.has(cacheKey)) {
    const cachedMatches = this.companyObjectCache.get(cacheKey);

    // Return first match if cached as array
    if (Array.isArray(cachedMatches) && cachedMatches.length > 0) {
      return cachedMatches[0];
    }

    // Return single value if cached as string
    if (typeof cachedMatches === "string") {
      return cachedMatches;
    }
  }

  console.log(`Looking up ${attributeName} object for: ${value}`);

  try {
    const normalizedAttrName = attributeName.toLowerCase();

    // Auto-discover Company object type if needed
    let objectTypeId = null;
    if (normalizedAttrName === "company" && !process.env.COMPANY_OBJECT_TYPE_ID) {
      console.log(`Auto-discovering Company object type...`);
      const schemas = await this.cloudApiClient.getSchemas();
      for (const schema of schemas) {
        const objectTypes = await this.cloudApiClient.getObjectTypes(schema.id);
        const companyType = objectTypes.find(
          (ot) => ot.name.toLowerCase() === "company"
        );
        if (companyType) {
          objectTypeId = companyType.id;
          console.log(`Found Company object type: ${companyType.name} (ID: ${objectTypeId})`);
          break;
        }
      }
    }

    // Build AQL query to find object by name OR key
    const escapedValue = this.escapeAQLValue(value);
    let aqlQuery = `Name = "${escapedValue}" OR Key = "${escapedValue}"`;

    // Add objectType filter for Company/Location/License (not Department)
    if (objectTypeId && normalizedAttrName !== "department") {
      aqlQuery += ` AND objectType = ${objectTypeId}`;
    }

    console.log(`AQL Query: ${aqlQuery}`);

    // Search for multiple matches (limit 10 instead of 1) to allow validation
    const response = await this.cloudApiClient.executeAQL(
      aqlQuery,
      0,
      10,
      true,
    );

    if (response && (response.objectEntries || response.values)) {
      const objectEntries = response.objectEntries || response.values;
      console.log(`Object entries found: ${objectEntries.length}`);

      if (objectEntries.length > 0) {
        // CACHE ALL potential matches BEFORE validation for fallback!
        const allPotentialMatches = objectEntries.map(
          (obj) => obj.key || obj.objectKey,
        );

        this.companyObjectCache.set(cacheKey, allPotentialMatches);

        // Validate each match and return first valid one
        for (const object of objectEntries) {
          if (this.validateObjectMatch(attributeName, value, object)) {
            const objectKey = object.key || object.objectKey;
            console.log(`Validated and found ${attributeName}: ${object.label || object.name} (Key: ${objectKey})`);
            return objectKey;
          }
        }
      }
    }

    return ""; // No object found
  } catch (error) {
    console.warn(`Error looking up ${attributeName}: ${error.message}`);
    return "";
  }
}
Enter fullscreen mode Exit fullscreen mode

Why This Matters - Business Rule Restrictions

Multiple Company objects might have the same name but different keys due to different schemas or object types:

Object entries found: 2 Match 1: Example Company (Key: UD-14341) Match 2: Example Company (Key: ADI-2952)

Problem: Some Company objects cannot be used with certain User object types due to business rule restrictions.

Solution: Cache ALL potential matches as an array. If the first match fails due to restrictions, we can automatically try the next match!

Solution 3: User Type Attribute Lookup

Jira User attributes reference Jira Platform users by accountId. This requires a separate API call to Jira REST API.

Implementation Code

async lookupUserAccountId(email) {
  // Check cache first to avoid repeated REST API calls
  if (this.userObjectCache.has(email)) {
    return this.userObjectCache.get(email);
  }

  console.log(`Looking up Jira User for email: ${email}`);

  try {
    // Search for Jira Platform user by email
    const jiraUser = await this.cloudApiClient.searchJiraUser(email);

    if (jiraUser && jiraUser.accountId) {
      // Cache the result
      this.userObjectCache.set(email, jiraUser.accountId);

      console.log(`Found Jira User: ${jiraUser.accountId} (${jiraUser.displayName})`);
      return jiraUser.accountId;
    } else {
      console.warn(`Jira User not found for email: ${email}`);
      return "";
    }
  } catch (error) {
    console.warn(`Error looking up Jira User: ${error.message}`);
    return "";
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in Update

// In updateUser(), handle Type 2 attributes
if (attrInfo.attributeType === 2) {
  convertedValue = await this.lookupUserAccountId(change.newValue);

  // Skip User type attributes if lookup failed (user not found in Assets)
  if (!convertedValue || convertedValue === "") {
    console.log(`Skipping ${attrInfo.attributeName} - user not found in Assets`);
    continue;
  }
}

// Then use the accountId for update
updatedAttributes.push({
  objectTypeAttributeId: attrInfo.attributeId,
  objectAttributeValues: [{ value: convertedValue }],
});
Enter fullscreen mode Exit fullscreen mode

Why This Matters

Different APIs with Different Rate Limits:

  • Microsoft Graph API uses OAuth with its own rate limits
  • Jira REST API has different rate limits than AQL-based Assets API
  • Caching Jira User lookups is critical to avoid hitting REST API rate limits

Solution 4: Data Preservation Strategy

Microsoft Graph is treated as the authority, but we should NEVER clear existing valid data from Assets. If Microsoft Graph doesn't have a value but the asset does, we MUST preserve the existing value.

Implementation Code

detectChanges(graphUser, asset) {
  const changes = [];

  for (const [graphAttr, attrInfo] of Object.entries(this.attributeMapping)) {
    let graphValue = graphUser[graphAttr];
    const assetValue = this.getAttributeValue(asset, attrInfo.attributeName);

    // CRITICAL: Preserve existing data if Graph has no value
    if (
      (graphValue === null ||
        graphValue === undefined ||
        graphValue === "") &&
      assetValue !== null &&
      assetValue !== undefined &&
      assetValue !== ""
    ) {
      continue; // Skip this change - preserve existing data!
    }

    // Normalization and comparison logic
    const normalizedGraphValue = this.normalizeValue(
      graphValue,
      attrInfo.attributeType
    );
    const normalizedAssetValue = this.normalizeValue(
      assetValue,
      attrInfo.attributeType
    );

    if (normalizedGraphValue !== normalizedAssetValue) {
      changes.push({
        attribute: attrInfo.attributeName,
        oldValue: assetValue || "",
        newValue: graphValue || "",
        graphAttribute: graphAttr,
        attributeType: attrInfo.attributeType,
      });
    }
  }

  return changes;
}
Enter fullscreen mode Exit fullscreen mode

Example Scenarios

Scenario 1: Preserve Company

  • Asset has: Company: "Example Company Inc."
  • Microsoft Graph has: companyName: ""
  • Result: No change detected, data preserved

Scenario 2: Update Empty Company

  • Asset has: Company: ""
  • Microsoft Graph has: companyName: "Example Company Inc."
  • Result: Change detected, will update

Scenario 3: Don't Clear Valid Company

  • Asset has: Company: "Example Company Inc."
  • Microsoft Graph has: companyName: ""
  • Result: No change detected, data preserved (NOT CLEARED!)

Solution 5: Error Handling & Fallback Mechanism

When updating assets, we might encounter errors like business rule restrictions or validation errors. The fallback mechanism removes failing attributes and retries with remaining ones.

Complete Implementation

async updateUser(asset, graphUser, changes) {
  console.log(`Updating asset ${asset.objectKey || asset.id}`);

  // Fetch existing object's current attributes to preserve them
  let existingAttributes = {};
  try {
    const existingObj = await this.cloudApiClient.getObject(asset.id);
    if (existingObj && existingObj.attributes) {
      for (const attr of existingObj.attributes) {
        existingAttributes[attr.objectTypeAttributeId] = attr;
      }
    }
  } catch (error) {
    console.warn(`Warning: Could not fetch existing attributes: ${error.message}`);
  }

  // Build attributes to update - merge new values with existing ones
  const updatedAttributes = [];

  // First, copy all existing attributes to preserve them
  for (const [attrId, attrValue] of Object.entries(existingAttributes)) {
    updatedAttributes.push({
      objectTypeAttributeId: parseInt(attrId),
      objectAttributeValues: attrValue.objectAttributeValues,
    });
  }

  const failedAttributes = [];

  // Then, update with new values for changed attributes
  for (const change of changes) {
    const attrInfo = this.attributeMapping[change.graphAttribute];
    if (attrInfo) {
      let convertedValue = this.convertValue(
        change.newValue,
        attrInfo.attributeType,
      );

      // Handle Object type attributes (like Department, License, Location, Company)
      if (attrInfo.attributeType === 1) {
        convertedValue = await this.lookupObjectId(
          attrInfo.attributeName,
          change.newValue,
        );
        // Skip Object type attributes if lookup failed
        if (!convertedValue || convertedValue === "") {
          console.log(`Skipping ${attrInfo.attributeName} - referenced object not found in Assets`);
          continue;
        }
      }

      // Handle User type attributes (like "Jira user")
      if (attrInfo.attributeType === 2) {
        convertedValue = await this.lookupUserAccountId(change.newValue);
        // Skip User type attributes if lookup failed
        if (!convertedValue || convertedValue === "") {
          console.log(`Skipping ${attrInfo.attributeName} - user not found in Assets`);
          continue;
        }
      }

      // Update or add the attribute
      const attrIndex = updatedAttributes.findIndex(
        (a) => a.objectTypeAttributeId === attrInfo.attributeId,
      );

      if (attrIndex >= 0) {
        // Update existing attribute
        updatedAttributes[attrIndex].objectAttributeValues = [
          { value: convertedValue },
        ];
      } else {
        // Add new attribute
        updatedAttributes.push({
          objectTypeAttributeId: attrInfo.attributeId,
          objectAttributeValues: [{ value: convertedValue }],
        });
      }
    }
  }

  const objectData = {
    objectTypeId: this.options.objectTypeId,
    attributes: updatedAttributes,
  };

  if (this.options.debug) {
    console.log("DEBUG: Sending to updateObject:");
    console.log(` objectId: ${asset.id}`);
    console.log(` objectTypeId: ${objectData.objectTypeId}`);
    console.log(" attributes:");
    updatedAttributes.forEach((attr) => {
      console.log(` ${attr.objectTypeAttributeId}: ${JSON.stringify(attr.objectAttributeValues[0].value)}`);
    });
  }

  // Keep trying until success or no more attributes to update
  while (updatedAttributes.length > 0) {
    try {
      const result = await this.cloudApiClient.updateObject(
        asset.id,
        objectData,
      );

      if (result) {
        console.log(`Updated successfully (${result.objectKey || result.id})`);

        // Log failed attributes if any
        if (failedAttributes.length > 0) {
          console.log(` ${failedAttributes.length} attribute(s) skipped due to errors:`);
          failedAttributes.forEach((failed) => {
            console.log(` - ${failed.attributeName}: ${failed.reason}`);
          });

          // Add to global error list for reporting
          this.errors.push({
            type: "attribute_update_failed",
            user: asset.label || asset.objectKey,
            userId: asset.id,
            failedAttributes: failedAttributes,
          });
        }

        return result;
      }
    } catch (error) {
      // Try to identify which attribute caused the error
      let failedAttrId = null;
      let failedAttrName = "unknown";
      let failureReason = error.message || "Unknown error";

      if (error.response) {
        let errorObj = error.response;

        // Try to parse JSON response
        if (typeof error.response === "string") {
          try {
            errorObj = JSON.parse(error.response);
          } catch (e) {
            errorObj = error.response;
          }
        }

        // Look for errors object in parsed JSON
        const errorsObj = errorObj.errors || errorObj;

        if (typeof errorsObj === "object" && errorsObj !== null) {
          // Iterate through error keys to find attribute restriction errors
          for (const [key, value] of Object.entries(errorsObj)) {
            const attrMatch = key.match(/^rlabs-insight-attribute-(\d+)$/);
            if (attrMatch && attrMatch[1]) {
              failedAttrId = parseInt(attrMatch[1]);
              failureReason = value || "Invalid due to restrictions";
              break;
            }
          }
        }
      }

      // Find the corresponding change
      const failedChange = changes.find((c) => {
        const attrInfo = this.attributeMapping[c.graphAttribute];
        return attrInfo && attrInfo.attributeId === failedAttrId;
      });

      if (failedChange) {
        console.log(`Failed to update ${failedChange.attributeName}: ${failureReason}`);
        console.log(`Removing this attribute and retrying...`);

        // Track the failed attribute
        failedAttributes.push({
          attributeId: failedAttrId,
          attributeName: failedChange.attributeName,
          reason: failureReason,
          oldValue: failedChange.oldValue,
          newValue: failedChange.newValue,
        });

        // Remove this attribute from update
        const attrIndex = updatedAttributes.findIndex(
          (a) => a.objectTypeAttributeId === failedAttrId,
        );
        if (attrIndex >= 0) {
          updatedAttributes.splice(attrIndex, 1);
        }

        // Remove this change
        const changeIndex = changes.indexOf(failedChange);
        if (changeIndex >= 0) {
          changes.splice(changeIndex, 1);
        }

        // Rebuild objectData with remaining attributes
        objectData.attributes = updatedAttributes;

        // Try again with remaining attributes
        continue;
      } else {
        // If we can't identify the failing attribute, log full error
        console.error(`Could not identify which attribute caused the error. Full error: ${error.message}`);
        console.error(`Error response: ${error.response || "No response"}`);
        throw error;
      }
    }
  }

  throw new Error(
    `Failed to update all attributes for ${asset.objectKey || asset.id}. See logs for details.`
  );
}
Enter fullscreen mode Exit fullscreen mode

Error Response Parsing

The code handles both string and object error responses:

// If error.response is a JSON string
if (typeof error.response === "string") {
  try {
    errorObj = JSON.parse(error.response);
  } catch (e) {
    errorObj = error.response;
  }
}

// Parse errors object
const errorsObj = errorObj.errors || errorObj;

// Iterate through error keys to find attribute restriction errors
for (const [key, value] of Object.entries(errorsObj)) {
  const attrMatch = key.match(/^rlabs-insight-attribute-(\d+)$/);
  if (attrMatch && attrMatch[1]) {
    failedAttrId = parseInt(attrMatch[1]);
    failureReason = value || "Invalid due to restrictions";
  }
}

Enter fullscreen mode Exit fullscreen mode

How the Fallback Works

  1. First Attempt - Try to update all attributes
  2. Error Encountered - API returns validation error for attribute 1486 (Company)
  3. Parse Error - Extract attribute ID from "rlabs-insight-attribute-1486"
  4. Identify Failed Change - Find the change that maps to attribute 1486
  5. Remove & Retry - Remove failing attribute from update, rebuild objectData
  6. Second Attempt - Try update again with remaining attributes
  7. Repeat Until Success - Keep removing failing attributes until success or none left to update
  8. Log Failures - Track all failed attributes in error array for manual fix

Complete Implementation Steps

Step 1: Set Up Environment Variables

Create a .env file in your project root:

# Microsoft Graph API Credentials
MS_CLIENT_ID=your-client-id
MS_TENANT_ID=your-tenant-id
MS_CLIENT_SECRET=your-client-secret

# Jira Cloud Assets Credentials
WORKSPACE_ID=your-workspace-id
CLOUD_API_TOKEN=your-api-token
CLOUD_BASE_URL=https://api.atlassian.com

# Optional Configuration
OBJECT_SCHEMA_ID=15
OBJECT_TYPE_ID=153
Enter fullscreen mode Exit fullscreen mode

Step 2: Initialize Attribute Mapping

const attributes = await this.cloudApiClient.getObjectTypeAttributes(
  this.options.objectTypeId
);

const mapping = {};
for (const attr of attributes) {
  const graphAttr = Object.keys(DEFAULT_MAPPING).find(
    key => DEFAULT_MAPPING[key] === attr.name
  );
  if (graphAttr) {
    mapping[graphAttr] = {
      attributeName: attr.name,
      attributeId: attr.id,
      attributeType: attr.type,    // Type codes: 0=Text, 1=Object, 2=User
    };
  }
}

this.attributeMapping = mapping;
Enter fullscreen mode Exit fullscreen mode

Step 3: Query Existing Users

const aqlQuery = `objectType = ${this.options.objectTypeId}`;

const response = await this.cloudApiClient.executeAQL(
  aqlQuery,
  0,
  500,    // Batch fetch all existing users
  true
);

// Build ID-to-name mapping cache from first sample entry
if (response.objectEntries && response.objectEntries.length > 0) {
  const sampleEntry = response.objectEntries[0];

  // Build mapping for attribute lookups
  for (const attr of sampleEntry.attributes) {
    this.attributeIdToNameMap.set(attr.objectTypeAttributeId, attr.name);
    this.attributeIdToTypeInfoMap.set(attr.objectTypeAttributeId, {
      name: attr.name,
      type: attr.type,
    });
  }

  // Build user lookup map
  for (const entry of response.objectEntries) {
    if (entry.attributes) {
      const nameAttr = entry.attributes.find(
        a => a.objectTypeAttributeId === 1478
      );
      if (nameAttr && nameAttr.objectAttributeValues[0]) {
        const name = nameAttr.objectAttributeValues[0].value;
        if (name) {
          this.existingUsersMap.set(this.normalizeKey(name), entry);
        }
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Step 4: Fetch Users from Microsoft Graph

const users = await this.graphApiClient.fetchUsers({
  filter: "userType eq 'Member'",
  select: Object.keys(ATTRIBUTE_MAPPING).join(","),
  maxResults: this.options.limit || 0,
  expandManager: true,    // Expand to get manager information
});

Enter fullscreen mode Exit fullscreen mode

Step 5: Process Each User

for (let i = 0; i < users.length; i++) {
  const user = users[i];
  const displayName = user.displayName || user.id || "Unknown";
  const mail = user.mail || "";
  const userPrincipalName = user.userPrincipalName || "";
  const managerInfo = user.manager
    ? `${user.manager.displayName} (${user.manager.mail || ""})`
    : "None";

  console.log(`[${i + 1}/${users.length}] Processing: ${displayName}`);
  console.log(` Email: ${mail || "N/A"}`);
  console.log(` UPN: ${userPrincipalName}`);
  console.log(` Manager: ${managerInfo}`);

  // Try to find matching existing user
  const existingAsset = await this.findMatchingUser(user);

  if (existingAsset) {
    this.stats.existingUsersMatched++;

    // Compare attributes to detect changes
    const changes = this.detectChanges(user, existingAsset);

    if (changes.length === 0) {
      console.log(`MATCHED: No changes detected`);
      this.stats.usersSkipped++;
    } else {
      console.log(`MATCHED: ${changes.length} change(s) detected`);
      changes.forEach((change) => {
        let changeMessage = ` - ${change.attribute}: "${change.oldValue}" → "${change.newValue}"`;

        // Add note for object reference attributes
        if (change.attributeType === 1) {
          changeMessage += ` (will look up object by name and use its ID)`;
        }

        console.log(changeMessage);
      });

      if (!this.options.dryRun) {
        await this.updateUser(existingAsset, user, changes);
        this.stats.existingUsersUpdated++;
      } else {
        console.log(` [DRY RUN] Would update asset ${existingAsset.objectKey || existingAsset.id}`);
        this.stats.usersSkipped++;
      }
    }
  } else {
    console.log(`NEW: Creating as new asset`);

    if (!this.options.dryRun) {
      await this.createUser(user);
      this.stats.newUsersCreated++;
    } else {
      console.log(` [DRY RUN] Would create new asset`);
      this.stats.usersSkipped++;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Save Report

saveReport() {
  const logsDir = path.join(__dirname, "..", "logs");
  if (!fs.existsSync(logsDir)) {
    fs.mkdirSync(logsDir, { recursive: true });
  }

  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
  const reportPath = path.join(logsDir, `ingestion-report-${timestamp}.json`);

  const report = {
    timestamp: new Date().toISOString(),
    options: this.options,
    duration: this.stats.endTime - this.stats.startTime,
    durationString: this.formatDuration(this.stats.endTime - this.stats.startTime),
    stats: this.stats,
    graphApiStats: this.graphApiClient.getStats(),
    cloudApiStats: this.cloudApiClient.getStats(),
    errors: this.errors,    // Include all failed attributes for manual fix
  };

  fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
  console.log(`Report saved to: ${reportPath}`);
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Always Use Caching

Cache all potential object matches, not just the first one. This enables fallback when business rules restrict certain objects.

2. Data Preservation is Critical

Never clear existing valid data from Assets. Only update when Microsoft Graph has a value and asset is empty.

3. Handle Rate Limits

Implement retry logic with exponential backoff for API calls. Jira REST API has different rate limits than AQL-based Assets API.

4. Comprehensive Error Logging

Track all failures in detailed error array with attribute IDs, names, reasons, and values. This enables manual review and fixes.

5. Test with Small Limits

Use --limit N flag to test with small batches before running full ingestion.

6. Use Dry-Run First

Always run --dry-run before actual ingestion to verify changes.

7. Monitor API Statistics

Track request counts and error rates to identify issues early.

Summary

This tutorial provides a production-tested solution for ingesting Microsoft Graph users into Jira Cloud Assets. The key learnings are:

Handle Multiple Attribute Types Correctly - Text (Type 0), Object References (Type 1), Users (Type 2)

Implement ID-to-Name Mapping - Enable attribute lookups by name instead of ID

Return Correct Values for Operations - displayValue for comparison, object ID for updates

Preserve Existing Data - Never clear valid data when Microsoft Graph is empty

Implement Fallback Mechanism - Handle business rule restrictions gracefully by removing failing fields and retrying

Log All Errors for Manual Fix - Track what couldn't be automated

By following this guide, you can build a robust ingestion system that handles complexities of Jira Assets API without relying on incomplete public documentation.


Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.

Top comments (0)