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>

jlaire [27.12.2025 01:38:07]

#

walkout_ kirjoitti:

Minulla on ongelma paginationissa listata Jira issueita.
[...]
Eli ei ole startAt vaan on nextPageToken ja limit. Eli requestin yhteydessä luodaan nextPageToken joka on string.

Mikä ongelmasi on tarkemmin? Millaiseen kyselyyn saat odottamattoman vastauksen? Koodisi ilmeisesti yrittää käyttää nextPageTokeneita.

Voin auttaa hintaan 500e + 100e/h.

Vastaus

Muista lukea kirjoitusohjeet.
Tietoa sivustosta