Skip to content

Commit

Permalink
Merge pull request #20 from nverges/4-import-with-estimates
Browse files Browse the repository at this point in the history
4 import with estimates
  • Loading branch information
nverges authored Nov 22, 2024
2 parents 195e22a + 137bdff commit 45b51f3
Show file tree
Hide file tree
Showing 11 changed files with 201 additions and 29 deletions.
43 changes: 22 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A command-line tool that migrates your Pivotal Tracker projects to Linear. Using

- Pivotal Stories → Linear Issues
- Pivotal Releases → Linear Parent Issues (with associated stories as sub-issues)
- Preserves attachments, comments, labels, assignees, priorities, and dates
- Preserves file attachments, comments, labels, statuses, priorities, estimates, assignees, subscribers and dates

Built with the [Linear SDK](https://github.com/linear/linear/tree/master/packages/sdk).

Expand All @@ -17,7 +17,8 @@ Built with the [Linear SDK](https://github.com/linear/linear/tree/master/package
- [Story Types](#story-types)
- [Releases](#releases) (Pivotal Releases → Linear parent issues with associated stories as sub-issues)
- [Priority](#priority)
- [Assignee](#assignee--smart-user_mapping) (Automatically matches Pivotal Users to Linear Member accounts)
- [Estimate](#estimate)
- [Assignee](#assignee) (Automatically matches Pivotal Users to Linear Member accounts)
- [Subscribers](#subscribers)
- [Created Date](#created-date)
- [Due Date](#due-date)
Expand Down Expand Up @@ -103,7 +104,20 @@ Linear Issues will be assigned a label with the corresponding Story Type (See [L
- Label: `pivotal - release`
- Associated stories as sub-issues

#### Assignee / Smart User Mapping

#### Priority

- Priority levels are mapped from Pivotal to Linear as follows:
- P1 (Pivotal) → High (Linear)
- P2 → Medium
- P3 → Low

### Estimate

- Prompts user to choose a new Estimate Scale
- Rounds pivotal estimate to nearest Linear value

#### Assignee

- Automatically matches Pivotal users to Linear team members by comparing names and emails
- Prompts for manual matching when automatic matching fails
Expand Down Expand Up @@ -143,13 +157,6 @@ Linear Issues will be assigned a label with the corresponding Story Type (See [L
- `Creator` will be set to the user who created the Personal API Key
- See **Raw Pivotal Tracker Data** comment for original value

#### Priority

- Priority levels are mapped from Pivotal to Linear as follows:
- P1 (Pivotal) → High (Linear)
- P2 → Medium
- P3 → Low

#### Created Date

- ⏰ Created Date of Pivotal Story will be preserved on the imported Linear Issue
Expand All @@ -160,14 +167,11 @@ Linear Issues will be assigned a label with the corresponding Story Type (See [L
- ✅ Due dates from Pivotal are copied exactly to Linear
- ❌ Stories without due dates in Pivotal will have no due date in Linear

#### Estimates
- [TODO](#todo)

#### Logger

- Unique Team data is stored in team-specific folders (`log/<team-name>`). Each folder contains:
- `output_<timestamp>.txt`: Complete console output for each import attempt
- `user_mapping.json` - Maps Pivotal Tracker usernames to Linear user accounts (see [Assignee / Smart User Mapping](#assignee--smart-user_mapping))
- `user_mapping.json` - Maps Pivotal Tracker usernames to Linear user accounts (see [Assignee](#Assignee))
- `successful_imports.csv` - Logs successfully imported Pivotal Stories. These will be skipped on subsequent import attempts, preventing duplicates.

> ⚠️ **WARNING**
Expand All @@ -177,14 +181,11 @@ Linear Issues will be assigned a label with the corresponding Story Type (See [L

#### Notes

- Add Team Members in Linear before beginning import to take advantage of Smart User matching. However, users can be manually mapped.
- Since your user account will be the Creator on every imported Issue, you will become a subscriber on every single Issue. You may want to consider using a burner account to perform these imports to avoid unwanted notifications.
- Be mindful of notification preferences for members. This can get noisy while importing 😬
- Add Team Members in Linear before beginning import to take advantage of Automatic User mapping. However, users can be manually mapped.
- You will become a subscriber on every Issue that's created with this importer. Adjust your subscription preferences accordingly, or consider using a burner account.
- Be mindful of notification preferences for your team members. This can get noisy while importing 😬

#### API Rate Limits

- Linear sets rate limits on their API usage, which you will probably reach. The Linear team was helpful in increasing my rate limits temporarily. https://developers.linear.app/docs/graphql/working-with-the-graphql-api/rate-limiting.
- The `MAX_REQUESTS_PER_SECOND` ENV var can be adjusted to throttle request frequency

#### TODO
- Pivotal Estimate -> Linear Estimate https://github.com/nverges/pivotal-linear-importer/issues/4
- The `MAX_REQUESTS_PER_SECOND` ENV var can be adjusted to throttle request frequency
Binary file modified image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/csv/_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function buildParams(row) {
labels: row["Labels"],
requestedBy: row["Requested By"],
ownedBy: row["Owned By"],
estimate: row["Estimate"],
comments: [...(comments || []), additionalPivotalData],
};

Expand Down
8 changes: 8 additions & 0 deletions src/csv/parse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import path from "path";
import { buildParams } from "./_utils.js";
import selectDirectory from "../prompts/select_csv_directory.js";

import { findClosestEstimate } from "../estimates/list.mjs";

// Function to list directories in the assets directory
async function listDirectories(directory) {
const items = await fs.readdir(directory, { withFileTypes: true });
Expand All @@ -20,6 +22,7 @@ function readCSV(filePath) {
const pivotalStories = [];
const releaseStories = [];
const labels = new Set();
const estimates = new Set();
const statusTypes = new Set();
const requestedBy = new Set();
const ownedBy = new Set();
Expand All @@ -41,6 +44,10 @@ function readCSV(filePath) {
statusTypes.add(row["Type"]);
}

if (row["Estimate"] && !isNaN(Number(row["Estimate"]))) {
estimates.add(Number(row["Estimate"]));
}

if (row["Requested By"]) {
requestedBy.add(row["Requested By"]);
}
Expand Down Expand Up @@ -91,6 +98,7 @@ function readCSV(filePath) {
pivotalUsers: Array.from(new Set([...ownedBy, ...requestedBy])),
requestedBy: Array.from(requestedBy),
ownedBy: Array.from(ownedBy),
estimates: Array.from(estimates).sort((a, b) => a - b),
}),
)
.on("error", reject);
Expand Down
2 changes: 1 addition & 1 deletion src/estimates/create.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ async function createEstimates({ teamId }) {
issueEstimationExtended: false,
issueEstimationAllowZero: true,
});
}
}

// console.log(chalk.green('✅ Issue Estimation enabled:') chalk.cyan(`${issueEstimationType}`));
console.log(chalk.green('✅ Issue Estimation enabled:'), chalk.cyan(`${issueEstimationType}`));
Expand Down
5 changes: 5 additions & 0 deletions src/estimates/estimation_scales.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const ESTIMATION_SCALES = {
exponential: [0, 1, 2, 4, 8, 16],
fibonacci: [0, 1, 2, 3, 5, 8],
linear: [0, 1, 2, 3, 4, 5],
};
38 changes: 38 additions & 0 deletions src/estimates/list.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import linearClient from "../../config/client.mjs";
import { ESTIMATION_SCALES } from "./estimation_scales.js";

export function findClosestEstimate(value, scale) {
if (!scale || !value) return null;

const numericValue = Number(value);
if (isNaN(numericValue)) return null;

return scale.reduce((closest, current) => {
return Math.abs(current - numericValue) < Math.abs(closest - numericValue) ? current : closest;
});
}

async function fetchEstimatesForTeam({ teamId }) {
if (!teamId) {
throw new Error("teamId is required");
}

try {
const team = await linearClient.team(teamId);
const issueEstimationType = await team.issueEstimationType;

const estimationScale = ESTIMATION_SCALES[issueEstimationType] || null;

const data = {
type: issueEstimationType,
scale: estimationScale,
};

return data;
} catch (error) {
console.error("Error fetching estimates:", error);
throw error;
}
}

export default fetchEstimatesForTeam;
42 changes: 42 additions & 0 deletions src/estimates/rounder.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ENABLE_DETAILED_LOGGING } from "../../config/config.js";
import { ESTIMATION_SCALES } from "./estimation_scales.js";
import chalk from "chalk";

export function findClosestEstimate(value, estimationScale) {
if (!estimationScale || !value) return null;

const scale = ESTIMATION_SCALES[estimationScale];
const numericValue = Number(value);

if (isNaN(numericValue)) return null;

if (ENABLE_DETAILED_LOGGING) {
console.log("value", value);
console.log(chalk.magenta("estimationScale"), estimationScale);
console.log(chalk.magenta("scale"), scale);
}

if (scale.includes(numericValue)) {
if (ENABLE_DETAILED_LOGGING) {
console.log(chalk.magenta("exact match found:"), numericValue);
}
return numericValue;
}

let closest = scale[0];
let minDiff = Math.abs(scale[0] - numericValue);

for (let i = 1; i < scale.length; i++) {
const diff = Math.abs(scale[i] - numericValue);
if (diff < minDiff) {
minDiff = diff;
closest = scale[i];
}
}

if (ENABLE_DETAILED_LOGGING) {
console.log(chalk.magenta("closest"), closest);
}

return closest;
}
13 changes: 8 additions & 5 deletions src/import.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,21 @@ import chalk from "chalk";

import selectTeam from "./teams/select.mjs";
import fetchStatuses from "./statuses/list.mjs";

// import deleteLabels from "./labels/delete.mjs";
import createLabels from "./labels/create.mjs";
import fetchLabels from "./labels/list.mjs";
import fetchIssuesForTeam from "./issues/list.mjs";
import createIssue from "./issues/create.mjs";

import importPivotalEstimates from "./prompts/import_pivotal_estimates.mjs";
import importFileAttachments from "./prompts/import_file_attachments.js";
import importLabelsFromCSV from "./prompts/import_labels_from_csv.js";
import selectStatusTypes from "./prompts/select_status_types.js";
import proceedWithImport from "./prompts/proceed_with_import.js";

// import fetchEstimatesForTeam from "./estimates/list.mjs";
// import createEstimates from "./estimates/create.mjs";

// import getTeamMembers from "./teams/members.mjs";

import { setupLogger } from "./logger/init.mjs";
Expand Down Expand Up @@ -49,6 +51,7 @@ const {
csvFilename,
pivotalUsers,
} = await parseCSV();
const estimationScale = await importPivotalEstimates({ teamId });
const { importFiles } = await importFileAttachments();
const { importLabels } = await importLabelsFromCSV();
const { selectedStatusTypes } = await selectStatusTypes(statusTypes);
Expand All @@ -70,7 +73,6 @@ const { userConfirmedProceed } = await proceedWithImport({
successfulImportsLength: successfulImports.size,
selectedStatusTypes,
});
// await createEstimates({ teamId }); // TODO: Add prompt to allow choosing issue estimation type

if (userConfirmedProceed) {
if (newReleaseStories.length + newPivotalStories.length === 0) {
Expand Down Expand Up @@ -132,14 +134,15 @@ if (userConfirmedProceed) {

try {
await createIssue({
importNumber,
teamId,
teamName,
pivotalStory,
stateId,
labelIds,
importNumber,
csvFilename,
importFiles,
estimationScale,
});
await logSuccessfulImport(pivotalStory.id, teamName);
} catch (error) {
Expand Down Expand Up @@ -208,15 +211,15 @@ if (userConfirmedProceed) {

try {
await createIssue({
importNumber,
teamId,
teamName,
pivotalStory,
stateId,
labelIds,
parentId: parentIssue?.id,
importNumber,
csvFilename,
importFiles,
estimationScale,
});
await logSuccessfulImport(pivotalStory.id, teamName);
} catch (error) {
Expand Down
7 changes: 5 additions & 2 deletions src/issues/create.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import upload from "../files/upload.mjs";
import { ENABLE_DETAILED_LOGGING } from "../../config/config.js";
import { exitProcess } from "../../config/config.js";

import { findClosestEstimate } from "../estimates/rounder.mjs";

import path from "path";

async function getUserMapping(teamName) {
Expand Down Expand Up @@ -41,15 +43,16 @@ async function getUserMapping(teamName) {
}

async function createIssue({
importNumber,
teamId,
teamName,
pivotalStory,
importNumber,
parentId,
stateId,
csvFilename,
importFiles,
labelIds,
estimationScale
}) {
try {
const userMapping = await getUserMapping(teamName);
Expand Down Expand Up @@ -150,7 +153,7 @@ async function createIssue({
assigneeId,
subscriberIds,
cycleId: null,
// estimate: [0, 1, 2, 4, 8, 16][Math.floor(Math.random() * 6)]
estimate: pivotalStory.estimate ? findClosestEstimate(pivotalStory.estimate, estimationScale) : undefined,
});

if (newIssue.success) {
Expand Down
Loading

0 comments on commit 45b51f3

Please sign in to comment.