Kirjautuminen

Haku

Tehtävät

Kilpailu

Putka Open 2025
Kilpailu on päättynyt.

Keskustelu: Ohjelmointikysymykset: Jira Cloud REST API ja Rearch Issue

walkout_ [24.12.2025 17:17:31]

#

Hei,

Minulla on ongelma paginationissa listata Jira issueita. Ja Copilot ja ChatGPT ei osaa auttaa, koska sen näkemys on jäljessä nykyiseen API-dokumenataatioon.

https://stackoverflow.com/questions/79853409/jira-forge-api-search-issues

https://community.developer.atlassian.com/t/how-to-do-pagination-for-jira-issues/98009

Documentaatio:

https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/#api-group-issue-search

Eli ei ole startAt vaan on nextPageToken ja limit. Eli requestin yhteydessä luodaan nextPageToken joka on string.

Voin maksaa auttajalle rahaakin jos hinnasta päästään sopimukseen. Ota yheyttä matti.kiviharju@i4ware.fi

walkout_ [24.12.2025 23:59:33]

#

Eli vielä.

NodeJS back-end:

export async function getIssues({
  project = null,
  year,
  month,
  limit = 50,
  issueKeys = null,
  pageToken = null,
} = {}) {
  if (typeof year !== 'number' || typeof month !== 'number') {
    throw new Error('getIssues requires { year: number, month: number }');
  }

  const safeLimit = Number(limit) && Number(limit) > 0 ? Math.min(Number(limit), 100) : 50;

  const dateCondition = 'updated >= -90d OR created >= -90d';
  const orderBy = 'ORDER BY created DESC';
  let jql = `${dateCondition} ${orderBy}`;

  if (issueKeys && Array.isArray(issueKeys) && issueKeys.length) {
    const escaped = issueKeys.map(k => `"${String(k).replace(/"/g, '\\"')}"`).join(', ');
    jql = `issuekey in (${escaped}) ${orderBy}`;
  } else if (project && project !== '-- ALL --') {
    jql = `project = "${project}" AND (${dateCondition}) ${orderBy}`;
  }

  console.log('getIssues JQL:', jql, 'pageToken:', pageToken, 'limit:', safeLimit);

  const url = route`/rest/api/3/search/jql`;
  let allIssues = [];
  let cursor = pageToken || null;

  try {
    const body = {
      expand: '',
      fields: ['*all'],
      fieldsByKeys: false,
      jql: jql,
      maxResults: safeLimit,
      nextPageToken: cursor || undefined,
      properties: []
    };

    console.log('Fetching issues with body:', JSON.stringify(body));

    const res = await api.asUser().requestJira(url, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(body)
    });

    if (res.status >= 400) {
      const text = await res.text();
      throw new Error(`Jira API error ${res.status}: ${text}`);
    }

    const json = await res.json();

    const issues = (json.issues || []).map(i => ({
      id: i.id,
      key: i.key,
      summary: i.fields.summary,
      avatarUrl: i.fields.issuetype.iconUrl,
      issueType: i.fields.issuetype.name,
      projectKey: i.fields.project.key
    }));

    allIssues.push(...issues);

    const nextToken = json.nextPageToken || null;

    // --- Compute worklogs per issue ---
    const daysInMonth = new Date(year, month + 1, 0).getDate();
    const fetchForIssue = async (iss) => {
      try {
        const wurl = route`/rest/api/3/issue/${iss.key}/worklog`;
        const wr = await api.asUser().requestJira(wurl, { method: 'GET', headers: { Accept: 'application/json' } });
        if (wr.status >= 400) return { iss, hoursByDay: Array(daysInMonth).fill(0), totalHours: 0 };
        const wjson = await wr.json();
        const hoursByDay = Array(daysInMonth).fill(0);
        (wjson.worklogs || []).forEach(w => {
          const iso = normalizeIsoTimezone(w.started || '');
          const d = new Date(iso);
          if (!isNaN(d.getTime()) && d.getFullYear() === year && d.getMonth() === month) {
            const dayIndex = d.getDate() - 1;
            const secs = Number(w.timeSpentSeconds) || 0;
            hoursByDay[dayIndex] += secs / 3600;
          }
        });
        for (let i = 0; i < hoursByDay.length; i++) hoursByDay[i] = +hoursByDay[i].toFixed(2);
        const totalHours = +hoursByDay.reduce((s, v) => s + v, 0).toFixed(2);
        return { iss, hoursByDay, totalHours };
      } catch {
        return { iss, hoursByDay: Array(daysInMonth).fill(0), totalHours: 0 };
      }
    };

    const results = await Promise.all(allIssues.map(fetchForIssue));
    const issuesWithHours = results.map(r => ({
      ...r.iss,
      hoursByDay: r.hoursByDay,
      totalHours: r.totalHours
    }));

    return {
      issues: issuesWithHours,
      pageToken: cursor,   // current token (used for prev)
      nextPageToken: nextToken,
      pageSize: safeLimit,
      total: issuesWithHours.length
    };

  } catch (err) {
    console.error('getIssues error:', err);
    throw err;
  }
}

Ja ReactJS front-end:

const fetchIssues = useCallback(async (token = null) => {
    const y = displayedMonth.getFullYear();
    const m = displayedMonth.getMonth(); // 0..11

    try {
      setLoadingIssues(true);

      // When using user's saved timesheet, pass explicit issue keys to backend
      // If using my timesheet but no keys saved, return empty list immediately
      if (useMyTimesheet && Array.isArray(myTimesheetKeys) && myTimesheetKeys.length === 0) {
        setIssues([]);
        setTotalIssues(0);
        setHours({});
        setLoadingIssues(false);
        return;
      }

      const result = await invoke('getIssues', {
        project: projectKey || null,
        year: y,
        month: m,
        limit: PAGE_SIZE,
        issueKeys: useMyTimesheet ? myTimesheetKeys : undefined,
        pageToken: token,
      });

      // Accept either { issues: [...] } or [...] directly
      const payload = Array.isArray(result)
        ? result
        : (result && Array.isArray(result.issues) ? result.issues : null);

      if (!payload) {
        console.warn('fetchIssues: invalid response', result);
        setIssues([]);
        setTotalIssues(0);
        return;
      }

      setIssues(payload);

      setNextPageToken(result.nextPageToken ?? null);

      if (token) {
        // push current token to stack for "prev" handling
        setPrevTokens(prev => [...prev, token]);
      }

      // If using saved personal timesheet selection, select all returned issues
      if (useMyTimesheet) {
        const sel = new Set();
        payload.forEach(i => sel.add(i.id));
        setSelectedIssues(sel);
      }

      // track total if provided
      setTotalIssues(result?.total ?? payload.length);

      // Populate hours map from backend hoursByDay data
      const newHours = {};
      payload.forEach((iss) => {
        newHours[iss.id] = iss.hoursByDay || Array(getDaysInMonth(y, m)).fill('');
      });
      setHours(newHours);

    } catch (error) {
      console.error('Error fetching issues:', error);
      showAlert('Failed to fetch issues: ' + (error.message || 'Unknown error'), 'danger');
      setIssues([]);
      setTotalIssues(0);
    } finally {
      setLoadingIssues(false);
    }
  }, [displayedMonth, projectKey, showAlert, useMyTimesheet, myTimesheetKeys]);

useEffect(() => {
    fetchIssues(null);
  }, [fetchIssues]);

  const handleNextPage = () => {
    if (nextPageToken) {
      fetchIssues(nextPageToken);
    }
  };

  const handlePrevPage = () => {
    if (prevTokens.length > 1) {
      // pop last token
      const newPrev = [...prevTokens];
      newPrev.pop();
      const token = newPrev[newPrev.length - 1];
      setPrevTokens(newPrev);
      fetchIssues(token);
    } else {
      // first page, no previous token
      fetchIssues(null);
      setPrevTokens([]);
    }
  };

<Button
                  className="page-btn-prev"
                  variant="light"
                  size="sm"
                  onClick={handlePrevPage}
                  disabled={page <= 1 || loadingIssues || useMyTimesheet}
                >{t('prevPage')}</Button>
                <Button
                  className="page-btn-next"
                  variant="light"
                  size="sm"
                  onClick={handleNextPage}
                  disabled={page * PAGE_SIZE >= totalIssues || loadingIssues || useMyTimesheet}
                >{t('nextPage')}</Button>

Vastaus

Muista lukea kirjoitusohjeet.
Tietoa sivustosta